<?php

/**
 * @see       https://github.com/laminas/laminas-console for the canonical source repository
 * @copyright https://github.com/laminas/laminas-console/blob/master/COPYRIGHT.md
 * @license   https://github.com/laminas/laminas-console/blob/master/LICENSE.md New BSD License
 */

namespace Laminas\Console\RouteMatcher;

use Laminas\Console\Exception;
use Laminas\Filter\FilterInterface;
use Laminas\Validator\ValidatorInterface;

class DefaultRouteMatcher implements RouteMatcherInterface
{
    /**
     * Parts of the route.
     *
     * @var array
     */
    protected $parts;

    /**
     * Default values.
     *
     * @var array
     */
    protected $defaults;

    /**
     * Parameters' name aliases.
     *
     * @var array
     */
    protected $aliases;

    /**
     * @var ValidatorInterface[]
     */
    protected $validators = [];

    /**
     * @var FilterInterface[]
     */
    protected $filters = [];

    /**
     * Class constructor
     *
     * @param string $route
     * @param array $constraints
     * @param array $defaults
     * @param array $aliases
     * @param array $filters
     * @param ValidatorInterface[] $validators
     * @throws Exception\InvalidArgumentException
     */
    public function __construct(
        $route,
        array $constraints = [],
        array $defaults = [],
        array $aliases = [],
        array $filters = null,
        array $validators = null
    ) {
        $this->defaults = $defaults;
        $this->constraints = $constraints;
        $this->aliases = $aliases;

        if ($filters !== null) {
            foreach ($filters as $name => $filter) {
                if (! $filter instanceof FilterInterface) {
                    throw new Exception\InvalidArgumentException(
                        'Cannot use ' . gettype($filters) . ' as filter for ' . __CLASS__
                    );
                }
                $this->filters[$name] = $filter;
            }
        }

        if ($validators !== null) {
            foreach ($validators as $name => $validator) {
                if (! $validator instanceof ValidatorInterface) {
                    throw new Exception\InvalidArgumentException(
                        'Cannot use ' . gettype($validator) . ' as validator for ' . __CLASS__
                    );
                }
                $this->validators[$name] = $validator;
            }
        }

        $this->parts = $this->parseDefinition($route);
    }

    /**
     * Parse a route definition.
     *
     * @param  string $def
     * @return array
     * @throws Exception\InvalidArgumentException
     */
    protected function parseDefinition($def)
    {
        $def    = trim($def);
        $pos    = 0;
        $length = strlen($def);
        $parts  = [];
        $unnamedGroupCounter = 1;
        $catchAllCount = 0;

        while ($pos < $length) {
            /**
             * Optional value param, i.e.
             *    [SOMETHING]
             */
            if (preg_match('/\G\[(?P<name>[A-Z][A-Z0-9\_\-]*?)\](?: +|$)/s', $def, $m, 0, $pos)) {
                $item = [
                    'name'       => strtolower($m['name']),
                    'literal'    => false,
                    'required'   => false,
                    'positional' => true,
                    'hasValue'   => true,
                ];
            } /**
             * Mandatory value param, i.e.
             *   SOMETHING
             */
            elseif (preg_match('/\G(?P<name>[A-Z][A-Z0-9\_\-]*?)(?: +|$)/s', $def, $m, 0, $pos)) {
                $item = [
                    'name'       => strtolower($m['name']),
                    'literal'    => false,
                    'required'   => true,
                    'positional' => true,
                    'hasValue'   => true,
                ];
            } /**
             * Optional literal param, i.e.
             *    [something]
             */
            elseif (preg_match('/\G\[ *?(?P<name>[a-zA-Z][a-zA-Z0-9\_\-\:]*?) *?\](?: +|$)/s', $def, $m, 0, $pos)) {
                $item = [
                    'name'       => $m['name'],
                    'literal'    => true,
                    'required'   => false,
                    'positional' => true,
                    'hasValue'   => false,
                ];
            } /**
             * Optional value param, syntax 2, i.e.
             *    [<something>]
             */
            elseif (preg_match('/\G\[ *\<(?P<name>[a-zA-Z][a-zA-Z0-9\_\-]*?)\> *\](?: +|$)/s', $def, $m, 0, $pos)) {
                $item = [
                    'name'       => $m['name'],
                    'literal'    => false,
                    'required'   => false,
                    'positional' => true,
                    'hasValue'   => true,
                ];
            } /**
             * Mandatory value param, i.e.
             *    <something>
             */
            elseif (preg_match('/\G\< *(?P<name>[a-zA-Z][a-zA-Z0-9\_\-]*?) *\>(?: +|$)/s', $def, $m, 0, $pos)) {
                $item = [
                    'name'       => $m['name'],
                    'literal'    => false,
                    'required'   => true,
                    'positional' => true,
                    'hasValue'   => true,
                ];
            } /**
             * Mandatory literal param, i.e.
             *   something
             */
            elseif (preg_match('/\G(?P<name>[a-zA-Z][a-zA-Z0-9\_\-\:]*?)(?: +|$)/s', $def, $m, 0, $pos)) {
                $item = [
                    'name'       => $m['name'],
                    'literal'    => true,
                    'required'   => true,
                    'positional' => true,
                    'hasValue'   => false,
                ];
            } /**
             * Mandatory long param
             *    --param=
             *    --param=whatever
             */
            elseif (preg_match(
                '/\G--(?P<name>[a-zA-Z0-9][a-zA-Z0-9\_\-]+)(?P<hasValue>=\S*?)?(?: +|$)/s',
                $def,
                $m,
                0,
                $pos
            )) {
                $item = [
                    'name'       => $m['name'],
                    'short'      => false,
                    'literal'    => false,
                    'required'   => true,
                    'positional' => false,
                    'hasValue'   => ! empty($m['hasValue']),
                ];
            } /**
             * Optional long flag
             *    [--param]
             */
            elseif (preg_match(
                '/\G\[ *?--(?P<name>[a-zA-Z0-9][a-zA-Z0-9\_\-]+) *?\](?: +|$)/s',
                $def,
                $m,
                0,
                $pos
            )) {
                $item = [
                    'name'       => $m['name'],
                    'short'      => false,
                    'literal'    => false,
                    'required'   => false,
                    'positional' => false,
                    'hasValue'   => false,
                ];
            } /**
             * Optional long param
             *    [--param=]
             *    [--param=whatever]
             */
            elseif (preg_match(
                '/\G\[ *?--(?P<name>[a-zA-Z0-9][a-zA-Z0-9\_\-]+)(?P<hasValue>=\S*?)? *?\](?: +|$)/s',
                $def,
                $m,
                0,
                $pos
            )) {
                $item = [
                    'name'       => $m['name'],
                    'short'      => false,
                    'literal'    => false,
                    'required'   => false,
                    'positional' => false,
                    'hasValue'   => ! empty($m['hasValue']),
                ];
            } /**
             * Mandatory short param
             *    -a
             *    -a=i
             *    -a=s
             *    -a=w
             */
            elseif (preg_match('/\G-(?P<name>[a-zA-Z0-9])(?:=(?P<type>[ns]))?(?: +|$)/s', $def, $m, 0, $pos)) {
                $item = [
                    'name'       => $m['name'],
                    'short'      => true,
                    'literal'    => false,
                    'required'   => true,
                    'positional' => false,
                    'hasValue'  => ! empty($m['type']) ? $m['type'] : null,
                ];
            } /**
             * Optional short param
             *    [-a]
             *    [-a=n]
             *    [-a=s]
             */
            elseif (preg_match(
                '/\G\[ *?-(?P<name>[a-zA-Z0-9])(?:=(?P<type>[ns]))? *?\](?: +|$)/s',
                $def,
                $m,
                0,
                $pos
            )) {
                $item = [
                    'name'       => $m['name'],
                    'short'      => true,
                    'literal'    => false,
                    'required'   => false,
                    'positional' => false,
                    'hasValue'  => ! empty($m['type']) ? $m['type'] : null,
                ];
            } /**
             * Optional literal param alternative
             *    [ something | somethingElse | anotherOne ]
             *    [ something | somethingElse | anotherOne ]:namedGroup
             */
            elseif (preg_match('/
                \G
                \[
                    (?P<options>
                        (?:
                            \ *?
                            (?P<name>[a-zA-Z][a-zA-Z0-9_\-]*?)
                            \ *?
                            (?:\||(?=\]))
                            \ *?
                        )+
                    )
                \]
                (?:\:(?P<groupName>[a-zA-Z0-9]+))?
                (?:\ +|$)
                /sx', $def, $m, 0, $pos)
            ) {
                // extract available options
                $options = preg_split('/ *\| */', trim($m['options']), 0, PREG_SPLIT_NO_EMPTY);

                // remove dupes
                $options = array_unique($options);

                // prepare item
                $item = [
                    'name'          => isset($m['groupName'])
                        ? $m['groupName']
                        : 'unnamedGroup' . $unnamedGroupCounter++,
                    'literal'       => true,
                    'required'      => false,
                    'positional'    => true,
                    'alternatives'  => $options,
                    'hasValue'      => false,
                ];
            } /**
             * Required literal param alternative
             *    ( something | somethingElse | anotherOne )
             *    ( something | somethingElse | anotherOne ):namedGroup
             */
            elseif (preg_match('/
                \G
                \(
                    (?P<options>
                        (?:
                            \ *?
                            (?P<name>[a-zA-Z][a-zA-Z0-9_\-]+)
                            \ *?
                            (?:\||(?=\)))
                            \ *?
                        )+
                    )
                \)
                (?:\:(?P<groupName>[a-zA-Z0-9]+))?
                (?:\ +|$)
                /sx', $def, $m, 0, $pos)) {
                // extract available options
                $options = preg_split('/ *\| */', trim($m['options']), 0, PREG_SPLIT_NO_EMPTY);

                // remove dupes
                $options = array_unique($options);

                // prepare item
                $item = [
                    'name'          => isset($m['groupName'])
                        ? $m['groupName']
                        : 'unnamedGroupAt' . $unnamedGroupCounter++,
                    'literal'       => true,
                    'required'      => true,
                    'positional'    => true,
                    'alternatives'  => $options,
                    'hasValue'      => false,
                ];
            } /**
             * Required long/short flag alternative
             *    ( --something | --somethingElse | --anotherOne | -s | -a )
             *    ( --something | --somethingElse | --anotherOne | -s | -a ):namedGroup
             */
            elseif (preg_match('/
                \G
                \(
                    (?P<options>
                        (?:
                            \ *?
                            \-+(?P<name>[a-zA-Z0-9][a-zA-Z0-9_\-]*?)
                            \ *?
                            (?:\||(?=\)))
                            \ *?
                        )+
                    )
                \)
                (?:\:(?P<groupName>[a-zA-Z0-9]+))?
                (?:\ +|$)
                /sx', $def, $m, 0, $pos)) {
                // extract available options
                $options = preg_split('/ *\| */', trim($m['options']), 0, PREG_SPLIT_NO_EMPTY);

                // remove dupes
                $options = array_unique($options);

                // remove prefix
                array_walk($options, function (&$val) {
                    $val = ltrim($val, '-');
                });

                // prepare item
                $item = [
                    'name'          => isset($m['groupName'])
                        ? $m['groupName']
                        : 'unnamedGroupAt' . $unnamedGroupCounter++,
                    'literal'       => false,
                    'required'      => true,
                    'positional'    => false,
                    'alternatives'  => $options,
                    'hasValue'      => false,
                ];
            } /**
             * Optional flag alternative
             *    [ --something | --somethingElse | --anotherOne | -s | -a ]
             *    [ --something | --somethingElse | --anotherOne | -s | -a ]:namedGroup
             */
            elseif (preg_match('/
                \G
                \[
                    (?P<options>
                        (?:
                            \ *?
                            \-+(?P<name>[a-zA-Z0-9][a-zA-Z0-9_\-]*?)
                            \ *?
                            (?:\||(?=\]))
                            \ *?
                        )+
                    )
                \]
                (?:\:(?P<groupName>[a-zA-Z0-9]+))?
                (?:\ +|$)
                /sx', $def, $m, 0, $pos)) {
                // extract available options
                $options = preg_split('/ *\| */', trim($m['options']), 0, PREG_SPLIT_NO_EMPTY);

                // remove dupes
                $options = array_unique($options);

                // remove prefix
                array_walk($options, function (&$val) {
                    $val = ltrim($val, '-');
                });

                // prepare item
                $item = [
                    'name'          => isset($m['groupName'])
                        ? $m['groupName']
                        : 'unnamedGroupAt' . $unnamedGroupCounter++,
                    'literal'       => false,
                    'required'      => false,
                    'positional'    => false,
                    'alternatives'  => $options,
                    'hasValue'      => false,
                ];
            } elseif (preg_match(
                '/\G\[ *?\.\.\.(?P<name>[a-zA-Z][a-zA-Z0-9\_\-\:]*?) *?\](?: +|$)/s',
                $def,
                $m,
                0,
                $pos
            )) {
                if ($catchAllCount > 0) {
                    throw new Exception\InvalidArgumentException(
                        'Cannot define more than one catchAll parameter'
                    );
                }
                $catchAllCount++;
                $item = [
                    'name'       => $m['name'],
                    'literal'    => false,
                    'required'   => false,
                    'catchAll'   => true,
                    'hasValue'   => true,
                ];
            } else {
                throw new Exception\InvalidArgumentException(
                    'Cannot understand Console route at "' . substr($def, $pos) . '"'
                );
            }

            if (! empty($item['positional']) && $catchAllCount > 0) {
                throw new Exception\InvalidArgumentException(
                    'Positional parameters must come before catchAlls'
                );
            }
            $pos += strlen($m[0]);
            $parts[] = $item;
        }

        return $parts;
    }

    /**
     * Returns list of names representing single parameter
     *
     * @param string $name
     * @return string
     */
    private function getAliases($name)
    {
        $namesToMatch = [$name];
        foreach ($this->aliases as $alias => $canonical) {
            if ($name == $canonical) {
                $namesToMatch[] = $alias;
            }
        }
        return $namesToMatch;
    }

    /**
     * Returns canonical name of a parameter
     *
     * @param string $name
     * @return string
     */
    private function getCanonicalName($name)
    {
        if (isset($this->aliases[$name])) {
            return $this->aliases[$name];
        }
        return $name;
    }

    /**
     * Match parameters against route passed to constructor
     *
     * @param array $params
     * @return array|null
     */
    public function match($params)
    {
        $matches = [];

        /*
         * Extract positional and named parts
         */
        $positional = $named = [];
        $catchAll = null;
        foreach ($this->parts as &$part) {
            if (isset($part['positional']) && $part['positional']) {
                $positional[] = &$part;
            } elseif (isset($part['catchAll']) && $part['catchAll']) {
                $catchAll = &$part;
                $matches[$catchAll['name']] = [];
            } else {
                $named[] = &$part;
            }
        }

        /*
         * Scan for named parts inside Console params
         */
        foreach ($named as &$part) {
            /*
             * Prepare match regex
             */
            if (isset($part['alternatives'])) {
                // an alternative of flags
                $regex = '/^\-+(?P<name>';

                $alternativeAliases = [];
                foreach ($part['alternatives'] as $alternative) {
                    $alternativeAliases[] = '(?:' . implode('|', $this->getAliases($alternative)) . ')';
                }

                $regex .= implode('|', $alternativeAliases);

                if ($part['hasValue']) {
                    $regex .= ')(?:\=(?P<value>.*?)$)?$/';
                } else {
                    $regex .= ')$/i';
                }
            } else {
                // a single named flag
                $name = '(?:' . implode('|', $this->getAliases($part['name'])) . ')';

                if ($part['short'] === true) {
                    // short variant
                    if ($part['hasValue']) {
                        $regex = '/^\-' . $name . '(?:\=(?P<value>.*?)$)?$/i';
                    } else {
                        $regex = '/^\-' . $name . '$/i';
                    }
                } elseif ($part['short'] === false) {
                    // long variant
                    if ($part['hasValue']) {
                        $regex = '/^\-{2,}' . $name . '(?:\=(?P<value>.*?)$)?$/i';
                    } else {
                        $regex = '/^\-{2,}' . $name . '$/i';
                    }
                }
            }

            /*
             * Look for param
             */
            $value = $param = null;
            for ($x = 0, $count = count($params); $x < $count; $x++) {
                if (preg_match($regex, $params[$x], $m)) {
                    // found param
                    $param = $params[$x];

                    // prevent further scanning of this param
                    array_splice($params, $x, 1);

                    if (isset($m['value'])) {
                        $value = $m['value'];
                    }

                    if (isset($m['name'])) {
                        $matchedName = $this->getCanonicalName($m['name']);
                    }

                    break;
                }
            }


            if (! $param) {
                /*
                 * Drop out if that was a mandatory param
                 */
                if ($part['required']) {
                    return;
                } /*
                 * Continue to next positional param
                 */
                else {
                    continue;
                }
            }


            /*
             * Value for flags is always boolean
             */
            if ($param && ! $part['hasValue']) {
                $value = true;
            }

            /*
             * Try to retrieve value if it is expected
             */
            if ((null === $value || "" === $value) && $part['hasValue']) {
                if ($x < count($params) + 1 && isset($params[$x])) {
                    // retrieve value from adjacent param
                    $value = $params[$x];

                    // prevent further scanning of this param
                    array_splice($params, $x, 1);
                } else {
                    // there are no more params available
                    return;
                }
            }

            /*
             * Validate the value against constraints
             */
            if ($part['hasValue'] && isset($this->constraints[$part['name']])) {
                if (! preg_match($this->constraints[$part['name']], $value)) {
                    // constraint failed
                    return;
                }
            }

            /*
             * Store the value
             */
            if ($part['hasValue']) {
                $matches[$part['name']] = $value;
            } else {
                $matches[$part['name']] = true;
            }

            /*
             * If there are alternatives, fill them
             */
            if (isset($part['alternatives'])) {
                if ($part['hasValue']) {
                    foreach ($part['alternatives'] as $alt) {
                        if ($alt === $matchedName && ! isset($matches[$alt])) {
                            $matches[$alt] = $value;
                        } elseif (! isset($matches[$alt])) {
                            $matches[$alt] = null;
                        }
                    }
                } else {
                    foreach ($part['alternatives'] as $alt) {
                        if ($alt === $matchedName && ! isset($matches[$alt])) {
                            $matches[$alt] = isset($this->defaults[$alt]) ? $this->defaults[$alt] : true;
                        } elseif (! isset($matches[$alt])) {
                            $matches[$alt] = false;
                        }
                    }
                }
            }
        }

        /*
         * Scan for left-out flags that should result in a mismatch
         */
        foreach ($params as $param) {
            if (preg_match('#^\-+#', $param)) {
                if (null === $catchAll) {
                    return; // there is an unrecognized flag
                }
            }
        }

        /*
         * Go through all positional params
         */
        $argPos = 0;
        foreach ($positional as &$part) {
            /*
             * Check if param exists
             */
            if (! isset($params[$argPos])) {
                if ($part['required']) {
                    // cannot find required positional param
                    return;
                } else {
                    // stop matching
                    break;
                }
            }

            $value = $params[$argPos];

            /*
             * Check if literal param matches
             */
            if ($part['literal']) {
                if ((isset($part['alternatives'])
                    && ! in_array($value, $part['alternatives']))
                    || (! isset($part['alternatives']) && $value != $part['name'])
                ) {
                    return;
                }
            }

            /*
             * Validate the value against constraints
             */
            if ($part['hasValue'] && isset($this->constraints[$part['name']])) {
                if (! preg_match($this->constraints[$part['name']], $value)) {
                    // constraint failed
                    return;
                }
            }

            /*
             * Store the value
             */
            if ($part['hasValue']) {
                $matches[$part['name']] = $value;
            } elseif (isset($part['alternatives'])) {
                // from all alternatives set matching parameter to TRUE and the rest to FALSE
                foreach ($part['alternatives'] as $alt) {
                    if ($alt == $value) {
                        $matches[$alt] = isset($this->defaults[$alt]) ? $this->defaults[$alt] : true;
                    } else {
                        $matches[$alt] = false;
                    }
                }

                // set alternatives group value
                $matches[$part['name']] = $value;
            } elseif (! $part['required']) {
                // set optional parameter flag
                $name = $part['name'];
                $matches[$name] = isset($this->defaults[$name]) ? $this->defaults[$name] : true;
            }

            /*
             * Advance to next argument
             */
            $argPos++;
        }

        /*
         * Check if we have consumed all positional parameters
         */
        if ($argPos < count($params)) {
            if (null !== $catchAll) {
                for ($i = $argPos; $i < count($params); $i++) {
                    $matches[$catchAll['name']][] = $params[$i];
                }
            } else {
                return; // there are extraneous params that were not consumed
            }
        }

        /*
         * Any optional flags that were not entered have value false
         */
        foreach ($this->parts as &$part) {
            if (! $part['required'] && ! $part['hasValue']) {
                if (! isset($matches[$part['name']])) {
                    $matches[$part['name']] = false;
                }
                // unset alternatives also should be false
                if (isset($part['alternatives'])) {
                    foreach ($part['alternatives'] as $alt) {
                        if (! isset($matches[$alt])) {
                            $matches[$alt] = false;
                        }
                    }
                }
            }
        }

        // run filters
        foreach ($matches as $name => $value) {
            if (isset($this->filters[$name])) {
                $matches[$name] = $this->filters[$name]->filter($value);
            }
        }

        // run validators
        $valid = true;
        foreach ($matches as $name => $value) {
            if (isset($this->validators[$name])) {
                $valid &= $this->validators[$name]->isValid($value);
            }
        }

        if (! $valid) {
            return;
        }

        return array_replace($this->defaults, $matches);
    }
}
