<?php

declare(strict_types=1);

namespace FecaMailshots\Domain;

final class DslParser
{
    /** @var list<DslToken> */
    private array $tokens = [];
    private int $pos = 0;

    /**
     * @return array{sources:list<string>, where:array<int, mixed>}
     */
    public function parse(string $input): array
    {
        $lexer = new DslLexer();
        $this->tokens = $lexer->lex($input);
        $this->pos = 0;

        $sources = $this->parseSourceExpr();
        $where = [];

        if ($this->matchKw('where')) {
            $where = $this->parsePredicateExpr();
        }

        $this->expect('EOF');

        return [
            'sources' => $sources,
            'where' => $where,
        ];
    }

    /** @return list<string> */
    private function parseSourceExpr(): array
    {
        $sources = [];
        $sources[] = $this->parseSourceRef();

        while ($this->matchKw('and')) {
            $sources[] = $this->parseSourceRef();
        }

        return array_values(array_unique($sources));
    }

    private function parseSourceRef(): string
    {
        $left = $this->expect('IDENT')->value;
        if ($this->match('.')) {
            $right = $this->expect('IDENT')->value;
            return $left . '.' . $right;
        }
        return strtolower($left);
    }

    /** @return array<int, mixed> */
    private function parsePredicateExpr(): array
    {
        $predicates = [];
        $predicates[] = $this->parsePredicateTerm();

        while ($this->matchKw('and')) {
            $predicates[] = $this->parsePredicateTerm();
        }

        return $predicates;
    }

    /** @return array<string, mixed> */
    private function parsePredicateTerm(): array
    {
        $negated = $this->matchKw('not');

        if ($this->match('(')) {
            $inner = $this->parsePredicateExpr();
            $this->expect(')');
            return ['type' => 'group', 'not' => $negated, 'items' => $inner];
        }

        $ident = $this->expect('IDENT')->value;

        if ($this->isBareFilter($ident)) {
            return ['type' => 'filter', 'not' => $negated, 'name' => strtolower($ident), 'args' => []];
        }

        if ($this->match('(')) {
            $args = [];
            if (!$this->match(')')) {
                $args = $this->parseValueList();
                $this->expect(')');
            }
            return ['type' => 'filter', 'not' => $negated, 'name' => strtolower($ident), 'args' => $args];
        }

        $lhs = $this->parseFieldRefFromIdent($ident);

        if ($this->matchKw('in')) {
            $this->expect('(');
            if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') {
                $rhs = $this->parseFieldRef();
                $this->expect(')');
                return ['type' => 'set_ref', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhs, 'op' => 'in'];
            }

            $rhsValues = $this->parseValueList();
            $this->expect(')');
            return ['type' => 'set_values', 'not' => $negated, 'lhs' => $lhs, 'values' => $rhsValues, 'op' => 'in'];
        }

        if ($this->matchKw('not')) {
            $this->expectKw('in');
            $this->expect('(');
            if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') {
                $rhs = $this->parseFieldRef();
                $this->expect(')');
                return ['type' => 'set_ref', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhs, 'op' => 'not in'];
            }

            $rhsValues = $this->parseValueList();
            $this->expect(')');
            return ['type' => 'set_values', 'not' => $negated, 'lhs' => $lhs, 'values' => $rhsValues, 'op' => 'not in'];
        }

        if ($this->matchKw('blank')) {
            return ['type' => 'blank', 'not' => $negated, 'lhs' => $lhs];
        }

        $opToken = $this->peek();
        $op = '';
        if ($opToken->type === 'OP') {
            $op = $this->next()->value;
        } elseif ($opToken->type === 'KW' && in_array($opToken->value, ['contains', 'starts-with', 'ends-with'], true)) {
            $op = $this->next()->value;
        } else {
            throw new AppError('dsl_parse', 'Expected comparison operator', ['offset' => $opToken->offset]);
        }

        if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') {
            $rhsField = $this->parseFieldRef();
            return ['type' => 'comparison_field', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhsField, 'op' => $op];
        }

        $value = $this->parseValue();
        return ['type' => 'comparison_value', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $value, 'op' => $op];
    }

    /** @return array{source:string, field:string} */
    private function parseFieldRefFromIdent(string $ident): array
    {
        $this->expect('.');
        $field = $this->expect('IDENT')->value;
        if ($this->match('.')) {
            $ident .= '.' . $field;
            $field = $this->expect('IDENT')->value;
        }
        return ['source' => strtolower($ident), 'field' => $field];
    }

    /** @return array{source:string, field:string} */
    private function parseFieldRef(): array
    {
        $src = strtolower($this->expect('IDENT')->value);
        $this->expect('.');
        $field = $this->expect('IDENT')->value;
        if ($this->match('.')) {
            $src .= '.' . strtolower($field);
            $field = $this->expect('IDENT')->value;
        }
        return ['source' => $src, 'field' => $field];
    }

    /** @return list<mixed> */
    private function parseValueList(): array
    {
        $values = [$this->parseValue()];
        while ($this->match(',')) {
            $values[] = $this->parseValue();
        }
        return $values;
    }

    /** @return mixed */
    private function parseValue()
    {
        $token = $this->next();
        if ($token->type === 'STRING') {
            return $token->value;
        }
        if ($token->type === 'NUMBER') {
            return (int) $token->value;
        }
        if ($token->type === 'KW' && ($token->value === 'true' || $token->value === 'false')) {
            return $token->value === 'true';
        }
        if ($token->type === 'KW' && $token->value === 'blank') {
            return ['type' => 'blank_literal'];
        }

        throw new AppError('dsl_parse', 'Expected literal value', ['offset' => $token->offset]);
    }

    private function match(string $type): bool
    {
        if ($this->peek()->type === $type) {
            $this->pos++;
            return true;
        }
        return false;
    }

    private function matchKw(string $value): bool
    {
        $token = $this->peek();
        if ($token->type === 'KW' && $token->value === $value) {
            $this->pos++;
            return true;
        }
        return false;
    }

    private function expect(string $type): DslToken
    {
        $token = $this->next();
        if ($token->type !== $type) {
            throw new AppError('dsl_parse', 'Unexpected token', ['expected' => $type, 'actual' => $token->type, 'offset' => $token->offset]);
        }
        return $token;
    }

    private function expectKw(string $value): DslToken
    {
        $token = $this->next();
        if ($token->type !== 'KW' || $token->value !== $value) {
            throw new AppError('dsl_parse', 'Expected keyword', ['expected' => $value, 'actual' => $token->value, 'offset' => $token->offset]);
        }
        return $token;
    }

    private function next(): DslToken
    {
        $token = $this->tokens[$this->pos] ?? new DslToken('EOF', '', 0);
        $this->pos++;
        return $token;
    }

    private function peek(int $offset = 0): DslToken
    {
        return $this->tokens[$this->pos + $offset] ?? new DslToken('EOF', '', 0);
    }

    private function isBareFilter(string $ident): bool
    {
        $filters = [
            'selected-renewal',
            'pending-renewal',
            'selected',
            'primary-contact',
            'fen1-contact',
            'pending-invoice',
            'selected-invoice',
            'member-or-affiliate-or-parish-council',
        ];
        $next = $this->peek();
        $blockedNext = ($next->type === '.') || ($next->type === 'OP') || ($next->type === 'KW' && in_array($next->value, ['in', 'not', 'contains', 'starts-with', 'ends-with'], true));
        return in_array(strtolower($ident), $filters, true) && !$blockedNext;
    }
}
