<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 3/18/16
 * Time: 5:43 PM
 */

namespace IMATHUZH\Qfq\Core;


use IMATHUZH\Qfq\Core\Helper\Support;

const NESTING_TOKEN_OPEN = '#&nesting-open-&#';
const NESTING_TOKEN_CLOSE = '#&nesting-close&#';
const NESTING_TOKEN_LENGTH = 17;


/**
 * Class BodytextParser
 * @package qfq
 */
class BodytextParser {

    protected $reportLinesTemp;
    protected $firstToken;
    public $reportLines;
    public $aliases;

    /**
     * @param string $bodyText
     *
     * @return mixed|string
     * @throws \UserFormException
     */
    public function process($bodyText) {

        $nestingOpen = '';
        $nestingClose = '';

        $bodyText = $this->trimAndRemoveCommentAndEmptyLine($bodyText, $nestingOpen, $nestingClose);
        // Encrypt double curly braces to prevent false positives with nesting: form = {{form}}\n
        $bodyText = Support::encryptDoubleCurlyBraces($bodyText);
        $bodyText = $this->joinLine($bodyText, $nestingOpen, $nestingClose);

        $bodyText = $this->encryptNestingDelimeter($bodyText, $nestingOpen, $nestingClose);
        $bodyText = $this->unNest($bodyText, $nestingOpen, $nestingClose);

        $bodyText = $this->trimAndRemoveCommentAndEmptyLine($bodyText, $nestingOpen, $nestingClose);
        $bodyText = Support::decryptDoubleCurlyBraces($bodyText);

        if (strpos($bodyText, NESTING_TOKEN_OPEN) !== false) {
            throw new \UserFormException(
                json_encode([ERROR_MESSAGE_TO_USER => 'Report: Missing close delimiter', ERROR_MESSAGE_TO_DEVELOPER => $bodyText]), ERROR_MISSING_CLOSE_DELIMITER);
        }

        unset($this->reportLinesTemp);

        return $bodyText;
    }

    /**
     * Trim all lines, remove all empty lines and  all lines which start with '#'
     *
     * @param $bodytext
     *
     * @param $nestingOpen
     * @param $nestingClose
     * @return string
     */

    private function trimAndRemoveCommentAndEmptyLine($bodytext, &$nestingOpen, &$nestingClose) {
        $data = array();
        $reportLines = array();

        $src = explode(PHP_EOL, $bodytext);
        if ($src === false) {
            return '';
        }

        $firstLine = trim($src[0]);

        foreach ($src as $key => $row) {
            $row = trim($row);

            if ($row === '' || $row[0] === '#') {
                continue;
            }
            $data[] = $row;

            // Increment $key to match line from tt-content record
            $key++;
            $reportLines[] = $key;
        }

        $this->reportLinesTemp = $reportLines;
        $this->setNestingToken($firstLine, $nestingOpen, $nestingClose);

        return implode(PHP_EOL, $data);
    }

    /**
     * Set the nesting token for this tt-content record. Valid tokens are {}, <>, [], ().
     * If the first line of bodytext is a comment line and the last char of that line is a valid token: set that one.
     * If not: set {} as nesting token.
     *
     * Example:
     *   # Some nice text       - no token found, take {}
     *   # ]                    - []
     *   # Powefull QFQ: <      - <>
     *
     * @param $firstLine
     * @param $nestingOpen
     * @param $nestingClose
     */
    private function setNestingToken($firstLine, &$nestingOpen, &$nestingClose) {

        if ($nestingOpen !== '') {
            return;  // tokens already set or not bodytext: do not change.
        }

        // Nothing defined: set default {}.
        if ($firstLine === false || $firstLine === '' || $firstLine[0] !== '#') {
            $nestingOpen = '{';
            $nestingClose = '}';

            return;
        }

        // Definition: first line of bodytext, has to be a comment line. If the last char is one of the valid token: set that one.
        // Nothing found: set {}.
        $nestingOpen = '{';
        $nestingClose = '}';

        if ($firstLine[0] === '#') {
            $token = substr($firstLine, -1);
            switch ($token) {
                case '<':
                    $nestingOpen = '<';
                    $nestingClose = '>';
                    break;
                case '[':
                    $nestingOpen = '[';
                    $nestingClose = ']';
                    break;
                case '(':
                    $nestingOpen = '(';
                    $nestingClose = ')';
                    break;
                default:
                    break;
            }
        }
    }

    /**
     * Join lines. Nesting isn't changed.
     *
     * Iterates over all lines.
     *   Is a line a 'new line'?
     *    no: concat it to the last one.
     *    yes: flush the buffer, start a new 'new line'
     *
     * New Line Trigger:
     * a: {
     * b: }
     * c: 20
     * d: 20.30
     *
     * e: 5 {
     * f: 5.10 {
     *
     * g: head =
     * h: 10.20.head =
     *
     *  c,d,e,f: ^\d+(\.\d+)*(\s*{)?$
     *  g,h:     ^(\d+\.)*(sql|head)\s*=
     *
     * @param $bodyText
     * @param $nestingOpen
     * @param $nestingClose
     * @return string
     */
    private function joinLine($bodyText, $nestingOpen, $nestingClose) {
        $data = array();
        $reportLines = $this->reportLinesTemp;
        $bodytextArray = explode(PHP_EOL, $bodyText);
        $firstToken = '';

        $nestingOpenRegexp = $nestingOpen;
        if ($nestingOpen === '(' || $nestingOpen === '[') {
            $nestingOpenRegexp = '\\' . $nestingOpen;
        }

        $full = '';
        $joinDelimiter = ' ';
        foreach ($bodytextArray as $key => $row) {

            // Line end with '\'?
            if (substr($row, -1) == '\\') {
                $row = trim(substr($row, 0, -1)); // remove last char and trim
                $joinDelimiterNext = '';
            } else {
                $joinDelimiterNext = ' ';
            }

            if (($row == $nestingOpen || $row == $nestingClose)
                || (1 === preg_match('/^\d+(\.\d+)*(\s*' . $nestingOpenRegexp . ')?$/', $row))
                || (1 === preg_match('/^(\d+\.)*(' . TOKEN_VALID_LIST . ')(?:\[\d*\])?\s*=/', $row))

                // Report notation 'alias'
                // E.g. myAlias { ...
                || (1 === preg_match('/^[\w-]*(\s*' . $nestingOpenRegexp . ')+$/', $row))
            ) {
                // if there is already something: save this.
                if ($full !== '') {
                    $data[] = $full;
                }

                // start new line
                $full = $row;

                // This later determines the notation mode
                // Possible values for $firstToken: '10', '10.20', '10.sql=...', '10.head=...', 'myAlias {', 'myAlias{'
                // Values such as 'form={{form:SE}}' are valid but not parsed as a level/alias.
                if (empty($firstToken) && 1 !== preg_match('/^(' . TOKEN_VALID_LIST . ')(?:\[\d*\])?\s*=/', $row)) {
                    $firstToken = (strpos($row, $nestingOpen) !== false) ? trim(substr($row, 0, strpos($row, $nestingOpen))) : $row;
                }

                // If the open delimiter is missing while using an alias, this is necessary to get the correct error message later on
                // Starts a new line if the previous line only contained '}'
                // It prevents that the lines '}' and 'myAlias' will be joined
            } elseif ($full === $nestingClose) {
                $data[] = $full;

                // start new line
                $full = $row;
            } else {
                // continue row: concat - the space is necessary to join SQL statements correctly: 'SELECT ... FROM ... WHERE ... AND\np.id=...'  - here a 'AND' and 'p.id' need a space.
                $full .= $joinDelimiter . $row;
                // remove unused elements
                unset($reportLines[$key]);
            }

            $joinDelimiter = $joinDelimiterNext;
        }

        // Save last line
        if ($full !== '') {
            $data[] = $full;
        }

        $reportLines = array_values($reportLines);

        // Combines line numbers ($key) from tt-content record with content ($value) from corresponding line: [line => content]
        // E.g. [0 => 4, 1 => "[", 2 => "sql = SELECT ...", 3 => "[", 4 => "sql = SELECT ...", ...]: line 0 is empty
        foreach ($reportLines as $key => $value) {
            $reportLines[$value] = $data[$key];
        }

        // Removes every element that is not an SQL statement: [line => content]
        // E.g. [2 => "sql = SELECT ...", 4 => "sql = SELECT ...", ...]
        foreach ($reportLines as $key => $value) {
            if (strpos($value, '=') !== false) {
                $arr = explode('"', $value, 2);
                if (strpos($arr[0], TOKEN_SQL) === false) {
                    unset($reportLines[$key]);
                }
            } else {
                unset($reportLines[$key]);
            }
        }
        $this->reportLinesTemp = $reportLines;
        $this->firstToken = $firstToken;

        return implode(PHP_EOL, $data);
    }

    /**
     * Encrypt $nestingOpen and $nestingClose by a more complex token. This makes it easy to search later for '}' or '{'
     *
     * Valid open (complete line): {, 10 {, 10.20 {
     * Valid close (complete line): }
     *
     * @param $bodytext
     *
     * @param $nestingOpen
     * @param $nestingClose
     * @return mixed
     */
    private function encryptNestingDelimeter($bodytext, $nestingOpen, $nestingClose) {

        if ($nestingOpen === '(' || $nestingOpen === '[') {
            $nestingOpen = '\\' . $nestingOpen;
            $nestingClose = '\\' . $nestingClose;
        }

        // Report notation 'numeric'
        // E.g. 10 { ...
        // $bodytext = preg_replace('/^((\d+)(\.\d+)*\s*)?(' . $nestingOpen . ')$/m', '$1' . NESTING_TOKEN_OPEN, $bodytext);

        // Report notation 'alias'
        // E.g. myAlias { ...
        $bodytext = preg_replace('/^((\s*[\w-]*\s*)|((\s*\d+)(\.\d+)*\s*))?(' . $nestingOpen . ')/m', '$1' . NESTING_TOKEN_OPEN, $bodytext);

        $bodytext = preg_replace('/^' . $nestingClose . '$/m', '$1' . NESTING_TOKEN_CLOSE, $bodytext);

        return $bodytext;
    }

    /**
     * Unnest all level.
     *
     * Input:
     * 10 {
     *   sql = SELECT
     *   20.sql = INSERT ..
     *   30 {
     *      sql = DELETE
     *   }
     * }
     *
     * Output:
     * 10.sql = SELECT
     * 10.20.sql = INSERT
     * 10.20.30.sql = DELETE
     *
     * @param $bodytext
     *
     * @param $nestingOpen
     * @param $nestingClose
     * @return mixed|string
     * @throws \UserFormException
     */
    private function unNest($bodytext, $nestingOpen, $nestingClose) {

        // Replace '\{' | '\}' by internal token. All remaining '}' | '{' means: 'nested'
//        $bodytext = str_replace('\{', '#&[_#', $bodytext);
//        $bodytext = str_replace('\}', '#&]_#', $bodytext);
//        $bodytext = Support::encryptDoubleCurlyBraces($bodytext);

        $result = $bodytext;
        $posFirstClose = strpos($result, NESTING_TOKEN_CLOSE);

        // Default: Report notation 'numeric'
        $notationMode = TOKEN_NOTATION_NUMERIC;
        $levels = null;
        $reportLines = $this->reportLinesTemp;
        $alias = null;
        $aliases = null;
        $firstToken = $this->firstToken;

        // No first token or non-numeric first token implies report notation 'alias'
        // It supports auto numbering of blocks and aliases
        if (empty($firstToken) || (1 !== preg_match('/^([0-9\._-])+$/', $firstToken) && !strpos($firstToken, '='))) {
            $notationMode = TOKEN_NOTATION_ALIAS;
            $aliases = array();
            $levels = array();
            $maxLevel = substr_count($bodytext, NESTING_TOKEN_CLOSE);

            // Generate an array containing all levels, e.g. [1,2,3,...]
            for ($x = 1; $x <= $maxLevel; $x++) {
                array_push($levels, $x);
            }
        }

        while ($posFirstClose !== false) {
            $posMatchOpen = strrpos(substr($result, 0, $posFirstClose), NESTING_TOKEN_OPEN);

            if ($posMatchOpen === false) {
                $result = $this->decryptNestingDelimeter($result, $nestingOpen, $nestingClose);
                throw new \UserFormException(
                    json_encode([ERROR_MESSAGE_TO_USER => 'Missing open delimiter', ERROR_MESSAGE_TO_DEVELOPER => "Missing open delimiter: $result"]),
                    ERROR_MISSING_OPEN_DELIMITER);
            }

            $pre = substr($result, 0, $posMatchOpen);
            if ($pre === false)
                $pre = '';

            $post = substr($result, $posFirstClose + NESTING_TOKEN_LENGTH);
            if ($post === false)
                $post = '';

            // trim also removes '\n'
            $match = trim(substr($result, $posMatchOpen + NESTING_TOKEN_LENGTH, $posFirstClose - $posMatchOpen - NESTING_TOKEN_LENGTH));

            // "10.sql = SELECT...\n20 {\n
            $levelStartPos = strrpos(trim($pre), PHP_EOL);
            $levelStartPos = ($levelStartPos === false) ? 0 : $levelStartPos + 1;  // Skip PHP_EOL

            $level = trim(substr($pre, $levelStartPos));

            // Report notation 'alias'
            // Count open brackets in front of current level
            // E.g. current $level = 2, then $index = 1, because there is 1 in front
            $index = substr_count($pre, NESTING_TOKEN_OPEN);

            // Check for report notation 'alias'
            if ($notationMode === TOKEN_NOTATION_ALIAS) {
                $aliasLevel = null;

                // $firstToken === $level checks if we are in the 'first loop'
                // $adjustLength is used later while extracting a substring and has to be zero in the first loop
                $adjustLength = ($firstToken === $level && strpos($pre, PHP_EOL) === false) ? 0 : 1;

                // If the $level, from which the $alias is extracted, nothing gets saved
                // Allowed characters: [a-zA-Z0-9\._-]
                // '.' is only allowed to detect report notation 'numeric'. This throws an error later on.
                $alias = (1 === preg_match('/^[a-zA-Z0-9\._-]+$/', $level) || $level === '') ? $level : null;

                // If no alias is set, then nothing gets saved
                if (!empty($alias)) {
                    // Construct absolute $level of the current $alias
                    // E.g. 1.2.3.
                    for ($x = 0; $x <= $index; $x++) {
                        $aliasLevel .= (isset($levels[$x])) ? $levels[$x] . '.' : null;
                    }

                    // Trailing '.' gets removed from $level: E.g. 1.2.3
                    // $level is saved together with $alias: [level => alias]
                    $aliases[substr($aliasLevel, 0, strlen($aliasLevel) - 1)] = $alias;
                }

                // Current $level can now be extracted from $levels [1,2,3,...]
                $level = (isset($levels[$index])) ? $levels[$index] : null;

                // Remove current $level from $levels [1,3,...]
                // This works because opening brackets get removed from $pre after every level
                unset($levels[$index]);

                // Reset keys
                $levels = array_values($levels);

                // Removes alias or level added by user to continue auto numbering scheme
                // E.g. User input: {\nsql = SELECT ...\n}\nmyAlias{\nsql = SELECT ...\n}
                // $pre = "1.sql = SELECT ...\nmyAlias" -> $pre = "1.sql = SELECT ...\n"
                $pre = substr($pre, 0, strrpos($pre, PHP_EOL) + $adjustLength);
            } else {

                // Remove 'level' from last line
                $pre = substr($pre, 0, $levelStartPos);
            }

            // Split nested content in single rows
            $lines = explode(PHP_EOL, $match);
            foreach ($lines as $line) {
                if ($line !== '') {
                    $pre .= $level . '.' . $line . PHP_EOL;
                }
            }

            $result = $pre . $post;
            $posFirstClose = strpos($result, NESTING_TOKEN_CLOSE);
        }

//        $result = str_replace('#&[_#', '{', $result);
//        $result = str_replace('#&]_#', '}', $result);
//        $result = Support::decryptDoubleCurlyBraces($result);

        $resultArr = explode(PHP_EOL, $result);

        // $value (previously SQL statement) gets replaced by its level: [line => level]
        // E.g. [2 => 1, 4 => 1.2, ...]:
        foreach ($reportLines as $keyLines => $valueLines) {
            foreach ($resultArr as $keyResult => $valueResult) {
                if (strpos($valueResult, '=')) {
                    $arr = explode("=", $valueResult, 2);
                    if (strpos($arr[0], TOKEN_SQL) !== false) {
                        $reportLines[$keyLines] = str_replace('.' . TOKEN_SQL, '', trim($arr[0]));
                    } else {
                        continue;
                    }
                } else {
                    continue;
                }
                unset($resultArr[$keyResult]);
                break;
            }
        }

        // Array is flipped: [level => line]
        // E.g. [1 => 2, 1.2 => 4, ...]
        $this->reportLines = array_flip($reportLines);

        $this->aliases = $aliases;

        return $result;
    }

    /**
     * Decrypt complex token by '{\n' and '}\n'
     *
     * @param $bodytext
     *
     * @param $nestingOpen
     * @param $nestingClose
     * @return mixed
     */
    private function decryptNestingDelimeter($bodytext, $nestingOpen, $nestingClose) {

        $bodytext = str_replace(NESTING_TOKEN_OPEN, "$nestingOpen\n", $bodytext);
        $bodytext = str_replace(NESTING_TOKEN_CLOSE, "$nestingClose\n", $bodytext);

        return $bodytext;
    }

}