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

namespace IMATHUZH\Qfq\Core\Parser;


/**
 * Class MixedTypeParser
 *
 * A parser for lists and dictionaries that can be nested.
 * Requires a string of six delimiters provided in this order:
 * - separator for list items
 * - key-value separator
 * - two delimiters (begin and end) for lists
 * - two delimiters (begin and end) for dictionaries
 * The parser can be restricted to only nested lists or only
 * nested dictionaries by providing a space instead of the
 * corresponding delimiters.
 *
 * @package qfq
 */
class MixedTypeParser extends SimpleParser {

    // Internally used constants
    const SEP = 0;
    const KVSEP = 1;
    const LIST_START = 2;
    const LIST_END = 3;
    const DICT_START = 4;
    const DICT_END = 5;

    /** @var string delimiters used by this parser */
    private string $delimiters = ',:[]{}';


    public function __construct(?string $delimiters = null, array $options = []) {
        if ($delimiters) {
            $this->delimiters = str_pad($delimiters, 6);
            $delimiters = str_replace(' ', '', $delimiters);
        } else {
            $delimiters = $this->delimiters;
        }
        parent::__construct($delimiters, $options);
    }

    /**
     * Parses the provided string into a literal value, a non-associative array,
     * or an associative array. The structures can be nested.
     *
     * @param string $data
     * @return mixed
     */
    public function parse(string $data) {
        $tokens = $this->tokenized($data);
        if ($tokens->valid()) {
            list($data, $empty, $delimiter) = $this->parseImpl($tokens);
            if ($delimiter) $this->raiseUnexpectedDelimiter($delimiter, $this->offset());
            return $data;
        } else {
            return $this->options[self::OPTION_EMPTY_VALUE];
        }
    }

    /**
     * Assumes the provided string is a list of items and parses
     * it into a non-associative array (possibly empty or with only
     * one element).
     *
     * @param string $data
     * @return array
     */
    public function parseList(string $data): array {
        $tokens = $this->tokenized($data);
        if ($tokens->valid()) {
            return $this->parseListImpl($tokens, null)[0];
        } else {
            return [];
        }
    }

    /**
     * Assumes that the provided string is a dictionary (i.e. a list
     * of key-value pairs) and parses it into an associative array.
     *
     * @param string $data
     * @return array
     */
    public function parseDictionary(string $data): array {
        $tokens = $this->tokenized($data);
        if ($tokens->valid()) {
            return $this->parseDictionaryImpl($tokens, null)[0];
        } else {
            return [];
        }
    }


    /**
     * The main method of the parser. It looks on the first token
     * and decides on the following action based on the delimiter
     * and the value of the token.
     *
     * @param \Generator $tokens
     * @return array
     */
    protected function parseImpl(\Generator $tokens): array {
        // Get a token and the bounding delimiter
        $tokenData = $tokens->current();
        $tokens->next();
        list($token, $delimiter) = $tokenData;
        $empty = $token->empty();
        switch ($delimiter) {
            case $this->delimiters[self::DICT_START]:
                // The opening delimiter of a dictionary cannot be preceded by a nonempty token
                $empty or $this->raiseUnexpectedToken($this->offset(), $delimiter, null);
                // Start parsing the string as a dictionary
                return $this->parseDictionaryImpl($tokens, $this->delimiters[self::DICT_END]);
            case $this->delimiters[self::LIST_START]:
                // The opening delimiter of a list cannot be preceded by a nonempty token
                $empty or $this->raiseUnexpectedToken($this->offset(), $delimiter, null);
                // Start parsing the string as a list
                return $this->parseListImpl($tokens, $this->delimiters[self::LIST_END]);
            default:
                // Otherwise process the obtained token
                return [$this->process($token), $empty, $delimiter];
        }
    }

    /**
     * A helper function that checks if a list of a dictionary is followed
     * directly by another delimiter (or the end of the string)
     *
     * @param \Generator $tokens
     * @param string|null $current
     * @return string|null            the next delimiter or null if none
     */
    private function checkNextDelimiter(\Generator $tokens, ?string $current): ?string {
        if ($current && $tokens->valid()) {
            list($token, $next) = $tokens->current();
            $token->empty() or $this->raiseUnexpectedToken($this->offset(), $current, $next);
            $tokens->next();
            return $next;
        } else {
            return null;
        }
    }

    /**
     * Processes the tokens into a list
     *
     * @param \Generator $tokens
     * @param string|null $endDelimiter
     * @return array
     */
    protected function parseListImpl(\Generator $tokens, ?string $endDelimiter): array {
        $result = [];
        do {
            // Get a value to add to the list
            list($value, $empty, $delimiter) = $this->parseImpl($tokens);
            switch ($delimiter) {
                case $this->delimiters[self::SEP]:
                    $result[] = $value;
                    break;
                case $endDelimiter:
                    // Add an empty element only if there was a comma
                    if (!$empty || $result) {
                        $result[] = $value;
                    }
                    // The end of the list - check if not followed by a non-empty token
                    $delimiter = $this->checkNextDelimiter($tokens, $delimiter);
                    return [$result, false, $delimiter];
                default:
                    // Only list item separators are allowed here
                    $this->raiseUnexpectedDelimiter(
                        $delimiter,
                        $this->offset(),
                        $this->delimiters[self::SEP] . $endDelimiter
                    );
            }
        } while ($tokens->valid());
        // The string ended with a comma - append an empty string unless
        // the list is expected to end with a delimiter
        if ($endDelimiter) $this->raiseUnexpectedEnd($endDelimiter);
        $result[] = $this->options['empty'];
        return [$result, false, null];
    }

    /**
     * Processes the tokens into a dictionary
     *
     * @param \Generator $tokens
     * @param string|null $endDelimiter
     * @return array
     */
    protected function parseDictionaryImpl(\Generator $tokens, ?string $endDelimiter): array {
        $result = [];
        do {
            // Get the key
            list($key, $delimiter) = $tokens->current();
            $key = strval($key);
            $tokens->next();
            if ($delimiter == $this->delimiters[self::KVSEP]) {
                // The key-value separator is found - find the corresponding value
                if ($tokens->valid()) {
                    list($value, $empty, $delimiter) = $this->parseImpl($tokens);
                } else {
                    // The end of the string - the value is empty
                    $delimiter = null;
                    $empty = true;
                }
                // Replace an empty token with the empty value
                $result[$key] = $empty ? (
                $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE]
                ) : $value;
            } elseif ($key) {
                // When no key-value separator, then do nothing it the key is empty
                // In other words: ignore trailing commas
                $result[$key] = $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE];
            }
            // Check if the current delimiter is a correct one
            if ($delimiter == $endDelimiter) {
                $delimiter = $this->checkNextDelimiter($tokens, $delimiter);
                return [$result, false, $delimiter];
            } elseif ($delimiter != $this->delimiters[self::SEP]) {
                $this->raiseUnexpectedDelimiter(
                    $delimiter,
                    $this->offset(),
                    $this->delimiters[self::SEP] . $endDelimiter
                );
            }
        } while ($tokens->valid());
        // Trailing commas are ok for objects
        return [$result, false, null];
    }
}
