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

namespace IMATHUZH\Qfq\Core\Parser;

/**
 * Represents a number prefixed with a sign. This class is used
 * to treat such values differently from absolute numbers.
 * This class implements `JsonSerializable`, so that it is
 * treated nicely by `json_encode()`.
 * @package qfq
 */
class SignedNumber implements \JsonSerializable {

    /** @var int|float the value of the number */
    public $value;

    public function __construct($value) {
        $this->value = $value;
    }

    public function __toString(): string {
        return $this->value > 0 ? "+$this->value" : "$this->value";
    }

    public function jsonSerialize() {
        return $this->value;
    }
}

/**
 * Class SimpleParser
 *
 * A basic parser that splits the provided string at unescaped
 * and unquoted delimiters. Token processing recognizes numbers
 * and specials values.
 *
 * @package qfq
 */
class SimpleParser extends StringTokenizer {

    /** @var string Option key: replace numeric strings with numbers */
    const OPTION_PARSE_NUMBERS = 'parse-numbers';

    /** @var string Option key: convert +num and -num to an instance of SignedNumber */
    const OPTION_KEEP_SIGN = 'keep-sign';

    /** @var string Option key: empty keys will be assigned their names are values */
    const OPTION_KEY_IS_VALUE = 'key-is-value';

    /** @var string Option key: the value assigned to empty tokens */
    const OPTION_EMPTY_VALUE = 'empty';

    /** @var array a configuration of the parser */
    public array $options = [
        self::OPTION_KEEP_SIGN => false,     // if true, tokens "+number" and "-number" are converted to instances of SignedNumber
        self::OPTION_KEY_IS_VALUE => false,  // if true, a key with no value is assigned its name
        self::OPTION_EMPTY_VALUE => null,    // the value used for empty tokens
        self::OPTION_PARSE_NUMBERS => true   // if true, tokens are replaced with numbers if possible
    ];

    /**
     * @var array A dictionary for special values of tokens. These values are
     *            used only for tokens for which the property `isString` is false.
     */
    public array $specialValues = [
        'null' => null,
        'true' => true,
        'false' => false,
        'yes' => true,
        'no' => false
    ];


    public function __construct(string $delimiters, array $options = []) {
        parent::__construct($delimiters);
        $this->options = array_merge($this->options, $options);
    }

    /**
     * Processes a token into a string, a number, or a special value.
     * @return mixed
     */
    protected function process(Token $token) {
        $asString = strval($token);
        if ($token->isString) {
            return $asString;
        } elseif ($asString === '') {
            return $this->options[self::OPTION_EMPTY_VALUE];
        } elseif ($this->options[self::OPTION_PARSE_NUMBERS] && is_numeric($asString)) {
            if (preg_match("/^[+-]?\d+$/", $asString)) {
                $value = intval($asString);
            } else {
                $value = floatval($asString);
            }
            return ($this->options[self::OPTION_KEEP_SIGN] && ($asString[0] === '+' || $asString[0] === '-'))
                ? new SignedNumber($value) : $value;
        } elseif (array_key_exists($asString, $this->specialValues)) {
            // isset() does not work, because the array has `null` as one of values
            return $this->specialValues[$asString];
        } else {
            return $asString;
        }
    }

    /**
     * A helper method that throws an exception
     * @param $delimiter
     * @param $position
     * @param $expected
     */
    protected static function raiseUnexpectedDelimiter($delimiter, $position, $expected = null): void {
        $msg = "An unexpected '$delimiter' at position $position";
        if ($expected) {
            $msg .= " while expecting " . implode(' or ', str_split($expected));
        }
        throw new \RuntimeException($msg);
    }

    /**
     * A helper method that throws an exception
     * @param $expected
     */
    protected static function raiseUnexpectedEnd($expected): void {
        throw new \RuntimeException("An unexpected end while searching for '$expected'");
    }

    /**
     * A helper method that throws an exception
     * @param $position
     * @param $before
     * @param $after
     */
    protected static function raiseUnexpectedToken($position, $before, $after): void {
        $msg = "An unexpected token at $position";
        $extra = [];
        if ($before) $extra[] = " before '$before'";
        if ($after) $extra[] = " after '$after'";
        $msg .= implode('and ', $extra);
        throw new \RuntimeException($msg);
    }

    /**
     * Parses the provided string into a list of values separated by delimiters.
     *
     * @param string $data
     * @return mixed
     */
    public function parse(string $data) {
        return iterator_to_array($this->iterate($data));
    }

    /**
     * Iterates over pieces of a string separated by unescaped delimiters.
     *
     * @param string $data
     * @return \Generator
     */
    public function iterate(string $data): \Generator {
        foreach ($this->tokenized($data) as $token) {
            yield $this->process($token[0]);
        }
    }
}
