<?php
/**
 * @package qfq
 * @author kputyr
 * @date: 09.11.2023
 */

namespace IMATHUZH\Qfq\Core\Parser;

/**
 * Class StringTokenizer
 *
 * This class is used to parse a string into a sequence of tokens
 * bound by provided delimiters. While parsing the string, pieces
 * of a token are generated each time a delimiter, a quote or
 * a backslash is found. They are joined together and yielded
 * once an unescaped delimiter is encountered or the end of
 * the string is reached.
 *
 * @project qfq
 */
class StringTokenizer {

    /** @var string a regexp pattern to match delimiters */
    private string $delimitersPattern;

    /** @var int the offset for the current token piece */
    private int $currentOffset = 0;

    /** @var TokenBuilder the object for building a token */
    protected TokenBuilder $tokenBuilder;


    public function __construct(string $delimiters) {
        $escapedDelimiters = str_replace(
            ['[', ']', '/', '.'],
            ['\\[', '\\]', '\\/', '\\.'],
            $delimiters
        );
        $this->delimitersPattern = "/[$escapedDelimiters\\\\\"']/";
        $this->tokenBuilder = new TokenBuilder();
    }

    /**
     * The offset of the last found delimiter or -1 otherwise
     * @return int
     */
    public function offset(): int {
        return $this->currentOffset - 1;
    }

    /**
     * Iterates over unescaped delimiters and quotes from a provided string.
     * At each iteration returns a delimiter or null when the end of the
     * string is reached.
     *
     * Examples
     * delimiters: ,:|
     *    ab:cd, ef|gh    -->  : , | (null)    (token pieces: 'ab' 'cd' 'ef' 'gh')
     *    ab\:c d,"e:f"   -->  , " : " (null)  (token pieces: 'ab:c d' '' 'e' 'f' '')
     *    ab\\:cd,' '     -->  : , (null)      (token pieces: 'ab\' 'cd' ' ')
     *
     * @param string $data the searched string
     * @return \Generator    delimiters
     */
    protected function unescapedDelimitersAndQuotes(string $data): \Generator {
        // Reset the token builder and offset
        $this->tokenBuilder->reset();
        $this->currentOffset = 0;
        // Match all delimiters, including escaped and inside quotes
        if (preg_match_all($this->delimitersPattern, $data, $delimiters, PREG_OFFSET_CAPTURE)) {
            // If non-empty, $delimiters is an array with one element:
            // a list of pairs [delimiter, offset]
            $delimiters = $delimiters[0];
            $tokenData = current($delimiters);
            while ($tokenData) {
                list($delimiter, $offset) = $tokenData;
                if ($delimiter == '\\') {
                    // The next character is escaped. If it is a delimiter,
                    // then we will ignore it and continue the search.
                    $tokenData = next($delimiters);
                    if (!$tokenData) {
                        // No more delimiters - we have reached the end of the string.
                        // We return the remaining part of the string outside the loop.
                        break;
                    } elseif ($tokenData[1] == $offset + 1) {
                        // This delimiter or quote is escaped by the backslash
                        if ($tokenData[0] != '\\') {
                            $this->tokenBuilder->append(substr($data, $this->currentOffset, $offset - $this->currentOffset));
                            $this->currentOffset = $tokenData[1];
                        }
                        $tokenData = next($delimiters);
                        continue;
                    }
                }
                // An unescaped delimiter has been found
                $this->tokenBuilder->append(substr($data, $this->currentOffset, $offset - $this->currentOffset));
                $this->currentOffset = $offset + 1;
                yield $delimiter;
                $tokenData = next($delimiters);
            }
        }
    }

    /**
     * Iterates over unescaped and unquoted delimiters from a provided string.
     * Unescaped quotes must match and are not included in the resulting token.
     * At each iteration returns a pair consisting of
     * - the token generated from the substring bound by the previous
     *   and the current delimiter
     * - the delimiter
     * Note that the offset of the current delimiter is given by the offset()
     * method. The delimiter is null when the end of the string is reached.
     *
     * Examples
     * delimiters: ,:|
     *    ab:cd, ef|gh    -->  ['ab', ':'] ['cd', ','] ['ef', '|'] ['gh', (null)]
     *    ab\:c d,"e:f"   -->  ['ab:c d', ','] ['e f', (null)]
     *    ab\\:cd,' '     -->  ['ab\', ':'] ['cd', ','] [' ', (null)]
     *
     * @param string $data the string to search for delimiters
     * @return \Generator    pairs [token, delimiter]
     */
    public function tokenized(string $data): \Generator {
        // Iterate over all unescaped delimiters
        $delimitersAndQuotes = $this->unescapedDelimitersAndQuotes($data);
        while ($delimitersAndQuotes->valid()) {
            $delimiter = $delimitersAndQuotes->current();
            if ($delimiter === '"' || $delimiter === "'") {
                // We will search for the matching quote and put everything
                // in between to the token
                $quote = $delimiter;
                $this->tokenBuilder->markQuote();
                while (true) {
                    // Get next delimiter and check if it is a matching quote
                    $delimitersAndQuotes->next();
                    if (!$delimitersAndQuotes->valid()) {
                        throw new \RuntimeException("An unexpected end while searching for '$delimiter'");
                    }
                    $delimiter = $delimitersAndQuotes->current();
                    if ($delimiter === $quote) {
                        // We have found a quote - break this loop and continue
                        // searching for delimiters
                        $this->tokenBuilder->markQuote();
                        break;
                    }
                    // An quoted delimiter is a part of the token
                    $this->tokenBuilder->pieces[] = $delimiter;
                }
            } else {
                // An unescaped delimiter: return the current token
                // and start building the next one
                yield [$this->tokenBuilder->process(), $delimiter];
            }
            $delimitersAndQuotes->next();
        }
        // No more delimiters: add the rest of the string and process the token
        $this->tokenBuilder->pieces[] = substr($data, $this->currentOffset);
        $token = $this->tokenBuilder->process();
        $token->empty() or yield [$token, null];
    }
}
