<?php
declare(strict_types=1);

namespace Help4BuilderSuite\Modules\Commerce;

use WP_Error;

if (! defined('ABSPATH')) {
    exit;
}

final class CustomerFieldFormula
{
    private const MAX_LENGTH = 500;
    private const MAX_TOKENS = 200;
    private const MAX_DEPTH = 24;
    private const FUNCTIONS = [
        'abs' => [1, 1],
        'ceil' => [1, 1],
        'floor' => [1, 1],
        'round' => [1, 2],
        'min' => [2, 2],
        'max' => [2, 2],
    ];

    private string $formula;
    private int $offset = 0;
    private int $tokens = 0;

    private function __construct(string $formula)
    {
        $this->formula = trim($formula);
    }

    /**
     * @return true|WP_Error
     */
    public static function validate(string $formula)
    {
        $parser = new self($formula);
        $parsed = $parser->parse();
        return $parsed instanceof WP_Error ? $parsed : true;
    }

    /**
     * @param array<string, mixed> $values
     * @return float|WP_Error
     */
    public static function evaluate(string $formula, array $values)
    {
        $parser = new self($formula);
        $parsed = $parser->parse();
        if ($parsed instanceof WP_Error) {
            return $parsed;
        }
        return $parser->evaluateNode($parsed, $values, 0);
    }

    /**
     * @return array<int, string>
     */
    public static function references(string $formula): array
    {
        preg_match_all('/\{([a-z0-9_]+)\}/', strtolower($formula), $matches);
        return array_values(array_unique(array_filter(array_map('sanitize_key', $matches[1] ?? []))));
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    private function parse()
    {
        if ($this->formula === '' || strlen($this->formula) > self::MAX_LENGTH) {
            return $this->error(__('Use a calculation formula no longer than 500 characters.', 'help4-builder-suite'));
        }
        $node = $this->parseExpression(0);
        if ($node instanceof WP_Error) {
            return $node;
        }
        $this->skipWhitespace();
        if ($this->offset !== strlen($this->formula)) {
            return $this->error(__('The calculation formula contains an unexpected token.', 'help4-builder-suite'));
        }
        return $node;
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    private function parseExpression(int $depth)
    {
        $left = $this->parseTerm($depth + 1);
        if ($left instanceof WP_Error) {
            return $left;
        }
        while (true) {
            $operator = $this->peekOperator(['+', '-']);
            if ($operator === '') {
                return $left;
            }
            $this->offset++;
            $right = $this->parseTerm($depth + 1);
            if ($right instanceof WP_Error) {
                return $right;
            }
            $left = ['kind' => 'binary', 'operator' => $operator, 'left' => $left, 'right' => $right];
        }
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    private function parseTerm(int $depth)
    {
        $left = $this->parseUnary($depth + 1);
        if ($left instanceof WP_Error) {
            return $left;
        }
        while (true) {
            $operator = $this->peekOperator(['*', '/']);
            if ($operator === '') {
                return $left;
            }
            $this->offset++;
            $right = $this->parseUnary($depth + 1);
            if ($right instanceof WP_Error) {
                return $right;
            }
            $left = ['kind' => 'binary', 'operator' => $operator, 'left' => $left, 'right' => $right];
        }
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    private function parseUnary(int $depth)
    {
        $depthError = $this->depthError($depth);
        if ($depthError instanceof WP_Error) {
            return $depthError;
        }
        $operator = $this->peekOperator(['+', '-']);
        if ($operator !== '') {
            $this->offset++;
            $value = $this->parseUnary($depth + 1);
            return $value instanceof WP_Error
                ? $value
                : ['kind' => 'unary', 'operator' => $operator, 'value' => $value];
        }
        return $this->parsePrimary($depth + 1);
    }

    /**
     * @return array<string, mixed>|WP_Error
     */
    private function parsePrimary(int $depth)
    {
        $depthError = $this->depthError($depth);
        if ($depthError instanceof WP_Error) {
            return $depthError;
        }
        if (! $this->consumeToken()) {
            return $this->error(__('The calculation formula is too complex.', 'help4-builder-suite'));
        }
        $this->skipWhitespace();
        $character = $this->formula[$this->offset] ?? '';
        if ($character === '(') {
            $this->offset++;
            $node = $this->parseExpression($depth + 1);
            if ($node instanceof WP_Error) {
                return $node;
            }
            $this->skipWhitespace();
            if (($this->formula[$this->offset] ?? '') !== ')') {
                return $this->error(__('Close every parenthesis in the calculation formula.', 'help4-builder-suite'));
            }
            $this->offset++;
            return $node;
        }
        if ($character === '{') {
            $closing = strpos($this->formula, '}', $this->offset + 1);
            if ($closing === false) {
                return $this->error(__('Close every field reference with a brace.', 'help4-builder-suite'));
            }
            $reference = substr($this->formula, $this->offset + 1, $closing - $this->offset - 1);
            if ($reference === '' || sanitize_key($reference) !== $reference) {
                return $this->error(__('Field references must use lowercase field keys inside braces.', 'help4-builder-suite'));
            }
            $this->offset = $closing + 1;
            return ['kind' => 'reference', 'key' => $reference];
        }
        if (preg_match('/\G(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/A', $this->formula, $match, 0, $this->offset) === 1) {
            $this->offset += strlen($match[0]);
            $number = (float) $match[0];
            return is_finite($number)
                ? ['kind' => 'number', 'value' => $number]
                : $this->error(__('Calculation numbers must be finite.', 'help4-builder-suite'));
        }
        if (preg_match('/\G([a-z][a-z0-9_]*)/A', $this->formula, $match, 0, $this->offset) === 1) {
            $function = strtolower($match[1]);
            $this->offset += strlen($match[0]);
            if (! isset(self::FUNCTIONS[$function])) {
                return $this->error(__('The calculation formula uses an unsupported function.', 'help4-builder-suite'));
            }
            $this->skipWhitespace();
            if (($this->formula[$this->offset] ?? '') !== '(') {
                return $this->error(__('Calculation functions require parentheses.', 'help4-builder-suite'));
            }
            $this->offset++;
            $arguments = [];
            while (true) {
                $arguments[] = $this->parseExpression($depth + 1);
                if ($arguments[count($arguments) - 1] instanceof WP_Error) {
                    return $arguments[count($arguments) - 1];
                }
                $this->skipWhitespace();
                $separator = $this->formula[$this->offset] ?? '';
                if ($separator === ',') {
                    $this->offset++;
                    continue;
                }
                if ($separator === ')') {
                    $this->offset++;
                    break;
                }
                return $this->error(__('Separate calculation function arguments with commas.', 'help4-builder-suite'));
            }
            [$minimum, $maximum] = self::FUNCTIONS[$function];
            if (count($arguments) < $minimum || count($arguments) > $maximum) {
                return $this->error(__('The calculation function has the wrong number of arguments.', 'help4-builder-suite'));
            }
            return ['kind' => 'function', 'name' => $function, 'arguments' => $arguments];
        }
        return $this->error(__('The calculation formula expected a number, field reference, or function.', 'help4-builder-suite'));
    }

    /**
     * @param array<string, mixed> $node
     * @param array<string, mixed> $values
     * @return float|WP_Error
     */
    private function evaluateNode(array $node, array $values, int $depth)
    {
        $depthError = $this->depthError($depth);
        if ($depthError instanceof WP_Error) {
            return $depthError;
        }
        $kind = (string) ($node['kind'] ?? '');
        if ($kind === 'number') {
            return (float) ($node['value'] ?? 0.0);
        }
        if ($kind === 'reference') {
            $key = (string) ($node['key'] ?? '');
            if (! array_key_exists($key, $values)) {
                return $this->error(__('A calculation references a field that has no value.', 'help4-builder-suite'));
            }
            $value = $values[$key];
            if ($value === '' || $value === null || $value === false) {
                return 0.0;
            }
            if ($value === true) {
                return 1.0;
            }
            if (! is_numeric($value)) {
                return $this->error(__('Calculated fields can only use numeric field values.', 'help4-builder-suite'));
            }
            $number = (float) $value;
            return is_finite($number)
                ? $number
                : $this->error(__('Calculated field inputs must be finite.', 'help4-builder-suite'));
        }
        if ($kind === 'unary') {
            $value = $this->evaluateNode((array) ($node['value'] ?? []), $values, $depth + 1);
            if ($value instanceof WP_Error) {
                return $value;
            }
            return (string) ($node['operator'] ?? '+') === '-' ? -$value : $value;
        }
        if ($kind === 'binary') {
            $left = $this->evaluateNode((array) ($node['left'] ?? []), $values, $depth + 1);
            if ($left instanceof WP_Error) {
                return $left;
            }
            $right = $this->evaluateNode((array) ($node['right'] ?? []), $values, $depth + 1);
            if ($right instanceof WP_Error) {
                return $right;
            }
            $operator = (string) ($node['operator'] ?? '');
            if ($operator === '/' && abs($right) < 0.000000000001) {
                return $this->error(__('A calculated field attempted to divide by zero.', 'help4-builder-suite'));
            }
            $result = match ($operator) {
                '+' => $left + $right,
                '-' => $left - $right,
                '*' => $left * $right,
                '/' => $left / $right,
                default => NAN,
            };
            return is_finite($result)
                ? $result
                : $this->error(__('The calculated field result is not finite.', 'help4-builder-suite'));
        }
        if ($kind === 'function') {
            $arguments = [];
            foreach ((array) ($node['arguments'] ?? []) as $argument) {
                $value = $this->evaluateNode((array) $argument, $values, $depth + 1);
                if ($value instanceof WP_Error) {
                    return $value;
                }
                $arguments[] = $value;
            }
            $result = match ((string) ($node['name'] ?? '')) {
                'abs' => abs($arguments[0]),
                'ceil' => ceil($arguments[0]),
                'floor' => floor($arguments[0]),
                'round' => round($arguments[0], max(-6, min(6, (int) ($arguments[1] ?? 0)))),
                'min' => min($arguments[0], $arguments[1]),
                'max' => max($arguments[0], $arguments[1]),
                default => NAN,
            };
            return is_finite((float) $result)
                ? (float) $result
                : $this->error(__('The calculated field result is not finite.', 'help4-builder-suite'));
        }
        return $this->error(__('The calculated field could not be evaluated.', 'help4-builder-suite'));
    }

    /**
     * @param array<int, string> $operators
     */
    private function peekOperator(array $operators): string
    {
        $this->skipWhitespace();
        $character = $this->formula[$this->offset] ?? '';
        return in_array($character, $operators, true) ? $character : '';
    }

    private function skipWhitespace(): void
    {
        $length = strlen($this->formula);
        while ($this->offset < $length && preg_match('/\s/', $this->formula[$this->offset]) === 1) {
            $this->offset++;
        }
    }

    private function consumeToken(): bool
    {
        $this->tokens++;
        return $this->tokens <= self::MAX_TOKENS;
    }

    /**
     * @return null|WP_Error
     */
    private function depthError(int $depth): ?WP_Error
    {
        return $depth > self::MAX_DEPTH
            ? $this->error(__('The calculation formula is nested too deeply.', 'help4-builder-suite'))
            : null;
    }

    private function error(string $message): WP_Error
    {
        return new WP_Error('h4bs_customer_field_formula', $message);
    }
}
