<?php

namespace ipl\Web\Filter;

use ipl\Stdlib\Events;
use ipl\Stdlib\Filter;

class Parser
{
    use Events;

    /** @var string Representation used to indicate end of input */
    public const EOL = 'EOL';

    /** @var string Emitted for every completely parsed condition */
    public const ON_CONDITION = 'on_condition';

    /** @var string Emitted for every completely parsed chain */
    public const ON_CHAIN = 'on_chain';

    /** @var string */
    protected $string;

    /** @var int */
    protected $pos;

    /** @var int */
    protected $termIndex;

    /** @var int */
    protected $length;

    /** @var bool Whether strict mode is enabled */
    protected $strict = false;

    /**
     * Create a new Parser
     *
     * @param string $queryString The string to parse
     */
    public function __construct($queryString = null)
    {
        if ($queryString !== null) {
            $this->setQueryString($queryString);
        }
    }

    /**
     * Set the query string to parse
     *
     * @param string $queryString
     *
     * @return $this
     */
    public function setQueryString($queryString)
    {
        $this->string = (string) $queryString;
        $this->length = strlen($queryString);

        return $this;
    }

    /**
     * Set whether strict mode is enabled
     *
     * @param bool $strict
     *
     * @return $this
     */
    public function setStrict($strict = true)
    {
        $this->strict = (bool) $strict;

        return $this;
    }

    /**
     * Parse the string and derive a filter rule from it
     *
     * @return Filter\Rule
     */
    public function parse()
    {
        if ($this->length === 0) {
            return Filter::all();
        }

        $this->pos = 0;
        $this->termIndex = 0;

        return $this->readFilters();
    }

    /**
     * Read filters
     *
     * @param int $nestingLevel
     * @param string $op
     * @param array $filters
     * @param bool $explicit
     *
     * @return Filter\Chain|Filter\Condition
     * @throws ParseException
     */
    protected function readFilters($nestingLevel = 0, $op = null, $filters = null, $explicit = true)
    {
        $filters = empty($filters) ? [] : $filters;
        $isNone = false;

        while ($this->pos < $this->length) {
            $filter = $this->readCondition();
            $next = $this->readChar();

            if ($filter === false) {
                if ($next === '!') {
                    $isNone = true;
                    $this->termIndex++;
                    continue;
                }

                if ($op === null && ($this->strict || count($filters) > 0) && ($next === '&' || $next === '|')) {
                    $op = $next;
                    $this->termIndex++;
                    continue;
                }

                if ($next === false) {
                    // Nothing more to read
                    break;
                }

                if ($next === ')') {
                    if ($nestingLevel > 0) {
                        if (! $explicit) {
                            // The current chain was not initiated by a `(`,
                            // so this `)` does not belong to it, but still ends it
                            $this->pos--;
                        } else {
                            $this->termIndex++;
                            $next = $this->nextChar();
                            if ($next !== false && ! in_array($next, ['&', '|', ')'])) {
                                $this->parseError($next, 'Expected logical operator');
                            }
                        }

                        break;
                    }

                    $this->parseError($next);
                }

                if ($next === '(') {
                    $this->termIndex++;

                    $rule = $this->readFilters($nestingLevel + 1, $isNone ? '!' : null);
                    if ($this->strict || ! $rule instanceof Filter\Chain || ! $rule->isEmpty()) {
                        $filters[] = $rule;
                    }

                    $isNone = false;
                    continue;
                }

                if ($next === $op) {
                    $this->termIndex++;
                    continue;
                }

                if (in_array($next, ['&', '|'])) {
                    $this->termIndex++;

                    // It's a different logical operator, continue parsing based on its precedence
                    if ($op === '&') {
                        if (! empty($filters)) {
                            if (count($filters) > 1) {
                                $all = Filter::all(...$filters);
                                $filters = [$all];

                                $this->emit(self::ON_CHAIN, [$all]);
                            } else {
                                $filters = [$filters[0]];
                            }
                        }

                        $op = $next;
                    } elseif ($op === '|' || ($op === '!' && $next === '&')) {
                        $rule = $this->readFilters(
                            $nestingLevel + 1,
                            $next,
                            [array_pop($filters)],
                            false
                        );
                        if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) {
                            $filters[] = $rule;
                        }
                    }

                    continue;
                }

                $this->parseError($next, sprintf("%slevel %d", $op ? "$op " : '', $nestingLevel));
            } else {
                if ($isNone) {
                    $isNone = false;
                    if ($filter->getValue() === true) {
                        // $filter is a result of `!column`
                        $filter->setValue(false);
                        $filters[] = $filter;

                        $this->emit(self::ON_CONDITION, [$filter]);
                    } else {
                        // $filter is a result of `!column=[value]`
                        $none = Filter::none($filter);
                        $filters[] = $none;

                        $this->emit(self::ON_CONDITION, [$filter]);
                        $this->emit(self::ON_CHAIN, [$none]);
                    }
                } else {
                    $filters[] = $filter;
                    $this->emit(self::ON_CONDITION, [$filter]);
                }

                if ($next === false) {
                    // Got filter, nothing more to read
                    break;
                }

                if ($next === ')') {
                    if ($nestingLevel > 0) {
                        if (! $explicit) {
                            // The current chain was not initiated by a `(`,
                            // so this `)` does not belong to it, but still ends it
                            $this->pos--;
                        } else {
                            $this->termIndex++;
                            $next = $this->nextChar();
                            if ($next !== false && ! in_array($next, ['&', '|', ')'])) {
                                $this->parseError($next, 'Expected logical operator');
                            }
                        }

                        break;
                    }

                    $this->parseError($next);
                }

                if ($next === $op) {
                    $this->termIndex++;
                    continue;
                }

                if (in_array($next, ['&', '|'])) {
                    $this->termIndex++;

                    // It's a different logical operator, continue parsing based on its precedence
                    if ($op === null || $op === '&') {
                        if ($op === '&') {
                            if (count($filters) > 1) {
                                $all = Filter::all(...$filters);
                                $filters = [$all];

                                $this->emit(self::ON_CHAIN, [$all]);
                            } else {
                                $filters = [$filters[0]];
                            }
                        }

                        $op = $next;
                    } elseif ($op === '|' || ($op === '!' && $next === '&')) {
                        $rule = $this->readFilters(
                            $nestingLevel + 1,
                            $next,
                            [array_pop($filters)],
                            false
                        );
                        if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) {
                            $filters[] = $rule;
                        }
                    }

                    continue;
                }

                $this->parseError($next);
            }
        }

        if ($nestingLevel === 0 && $this->pos < $this->length) {
            $this->parseError($op, 'Did not read full filter');
        }

        switch ($op) {
            case '&':
                $chain = Filter::all(...$filters);
                break;
            case '|':
                $chain = Filter::any(...$filters);
                break;
            case '!':
                $chain = Filter::none(...$filters);
                break;
            case null:
                if ((! $this->strict || $nestingLevel === 0) && ! empty($filters)) {
                    // There is only one filter expression, no chain
                    return $filters[0];
                }

                $chain = Filter::all(...$filters);
                break;
            default:
                $this->parseError($op);
        }

        $this->emit(self::ON_CHAIN, [$chain]);

        return $chain;
    }

    /**
     * Read the next condition
     *
     * @return false|Filter\Condition
     *
     * @throws ParseException
     */
    protected function readCondition()
    {
        if ('' === ($column = $this->readColumn())) {
            return false;
        }

        $columnIndex = $this->termIndex++;

        foreach (['<', '>'] as $operator) {
            if (($pos = strpos($column, $operator)) !== false) {
                if ($this->nextChar() === '=') {
                    break;
                }

                $operatorIndex = $this->termIndex++;

                $value = substr($column, $pos + 1);
                $column = substr($column, 0, $pos);

                $valueIndex = null;
                if (ctype_digit($value)) {
                    $value = (float) $value;
                    $valueIndex = $this->termIndex++;
                } elseif ($value) {
                    $valueIndex = $this->termIndex++;
                }

                $condition = $this->createCondition($column, $operator, $value);
                $condition->metaData()
                    ->set('columnIndex', $columnIndex)
                    ->set('operatorIndex', $operatorIndex)
                    ->set('valueIndex', $valueIndex);

                return $condition;
            }
        }

        if (in_array($this->nextChar(), ['~', '=', '>', '<', '!'], true)) {
            $operator = $this->readChar();
        } else {
            $operator = false;
        }

        if ($operator === false) {
            $condition = Filter::equal($column, true);
            $condition->metaData()
                ->set('columnIndex', $columnIndex)
                ->set('operatorIndex', null)
                ->set('valueIndex', null);

            return $condition;
        }

        $operatorIndex = $this->termIndex++;

        $toFloat = false;
        if ($operator === '=') {
            $last = substr($column, -1);
            if ($last === '>' || $last === '<') {
                $operator = $last . $operator;
                $column = substr($column, 0, -1);
                $toFloat = true;
            }
        } elseif (in_array($operator, ['>', '<', '!'], true)) {
            $toFloat = $operator === '>' || $operator === '<';
            if (in_array($this->nextChar(), ['~', '='], true)) {
                $operator .= $this->readChar();
            }
        }

        $valueIndex = null;
        $value = $this->readValue();
        if ($toFloat && ctype_digit($value)) {
            $value = (float) $value;
            $valueIndex = $this->termIndex++;
        } elseif ($value) {
            $valueIndex = $this->termIndex++;
        }

        $condition = $this->createCondition($column, $operator, $value);
        if ($condition === null) {
            // Rewind to the value start for accurate error reporting
            $this->pos -= is_array($value)
                ? strlen(implode('|', $value)) + 2 // Account for the parentheses
                : strlen($value);
            $this->parseError(null, 'Invalid operator in column expression');
        }

        $condition->metaData()
            ->set('columnIndex', $columnIndex)
            ->set('operatorIndex', $operatorIndex)
            ->set('valueIndex', $valueIndex);

        return $condition;
    }

    /**
     * Read the next column
     *
     * @return false|string false if there is none
     */
    protected function readColumn()
    {
        $str = $this->readUntil('~', '=', '(', ')', '&', '|', '>', '<', '!');

        if ($str === false) {
            return $str;
        }

        return rawurldecode($str);
    }

    /**
     * Read the next value
     *
     * @return string|string[]
     *
     * @throws ParseException In case there's a missing `)`
     */
    protected function readValue()
    {
        if ($this->nextChar() === '(') {
            $this->readChar();
            $var = array_map('rawurldecode', preg_split('~\|~', $this->readUntil(')')));

            if ($this->readChar() !== ')') {
                $this->parseError(null, 'Expected ")"');
            }
        } else {
            $var = rawurldecode($this->readUntil(')', '&', '|', '>', '<'));
        }

        return $var;
    }

    /**
     * Read until any of the given chars appears
     *
     * @param string ...$chars
     *
     * @return string
     */
    protected function readUntil(...$chars)
    {
        $buffer = '';
        while (($c = $this->readChar()) !== false) {
            if (in_array($c, $chars, true)) {
                $this->pos--;
                break;
            }

            $buffer .= $c;
        }

        return $buffer;
    }

    /**
     * Read a single character
     *
     * @return false|string false if there is no character left
     */
    protected function readChar()
    {
        if ($this->length > $this->pos) {
            return $this->string[$this->pos++];
        }

        return false;
    }

    /**
     * Look at the next character
     *
     * @return false|string false if there is no character left
     */
    protected function nextChar()
    {
        if ($this->length > $this->pos) {
            return $this->string[$this->pos];
        }

        return false;
    }

    /**
     * Create and return a condition
     *
     * @param string $column
     * @param string $operator
     * @param mixed  $value
     *
     * @return Filter\Condition
     */
    protected function createCondition($column, $operator, $value)
    {
        $column = trim($column);

        switch ($operator) {
            case '~':
                return Filter::like($column, $value);
            case '!~':
                return Filter::unlike($column, $value);
            case '=':
                return Filter::equal($column, $value);
            case '!=':
                return Filter::unequal($column, $value);
            case '>':
                return Filter::greaterThan($column, $value);
            case '>=':
                return Filter::greaterThanOrEqual($column, $value);
            case '<':
                return Filter::lessThan($column, $value);
            case '<=':
                return Filter::lessThanOrEqual($column, $value);
        }
    }

    /**
     * Throw a parse exception
     *
     * @param string $char
     * @param string $extraMsg
     *
     * @throws ParseException
     */
    protected function parseError($char = null, $extraMsg = null)
    {
        if ($extraMsg === null) {
            $extra = '';
        } else {
            $extra = ': ' . $extraMsg;
        }

        $pos = $this->pos;
        if ($char === null) {
            if ($this->pos < $this->length) {
                $char = $this->string[$this->pos];
            } else {
                $char = self::EOL;
            }
        } elseif (! isset($this->string[$pos]) || $this->string[$pos] !== $char) {
            $pos--;
        }

        throw new ParseException(
            $this->string,
            $char,
            $pos,
            $extra
        );
    }
}
