<?php

declare(strict_types=1);

/*
 * The MIT License (MIT)
 *
 * Copyright (c) 2013 Jonathan Vollebregt (jnvsor@gmail.com), Rokas Šleinius (raveren@gmail.com)
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of
 * this software and associated documentation files (the "Software"), to deal in
 * the Software without restriction, including without limitation the rights to
 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
 * the Software, and to permit persons to whom the Software is furnished to do so,
 * subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
 * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

namespace Kint;

/**
 * @psalm-type PhpTokenArray = array{int, string, int}
 * @psalm-type PhpToken = string|PhpTokenArray
 * @psalm-type CallParameter = array{
 *   name: string,
 *   path: string,
 *   expression: bool,
 *   literal: bool,
 *   new_without_parens: bool,
 * }
 */
class CallFinder
{
    private static array $ignore = [
        T_CLOSE_TAG => true,
        T_COMMENT => true,
        T_DOC_COMMENT => true,
        T_INLINE_HTML => true,
        T_OPEN_TAG => true,
        T_OPEN_TAG_WITH_ECHO => true,
        T_WHITESPACE => true,
    ];

    /**
     * Things we need to do specially for operator tokens:
     * - Refuse to strip spaces around them
     * - Wrap the access path in parentheses if there
     *   are any of these in the final short parameter.
     */
    private static array $operator = [
        T_AND_EQUAL => true,
        T_BOOLEAN_AND => true,
        T_BOOLEAN_OR => true,
        T_ARRAY_CAST => true,
        T_BOOL_CAST => true,
        T_CLONE => true,
        T_CONCAT_EQUAL => true,
        T_DEC => true,
        T_DIV_EQUAL => true,
        T_DOUBLE_CAST => true,
        T_FUNCTION => true,
        T_INC => true,
        T_INCLUDE => true,
        T_INCLUDE_ONCE => true,
        T_INSTANCEOF => true,
        T_INT_CAST => true,
        T_IS_EQUAL => true,
        T_IS_GREATER_OR_EQUAL => true,
        T_IS_IDENTICAL => true,
        T_IS_NOT_EQUAL => true,
        T_IS_NOT_IDENTICAL => true,
        T_IS_SMALLER_OR_EQUAL => true,
        T_LOGICAL_AND => true,
        T_LOGICAL_OR => true,
        T_LOGICAL_XOR => true,
        T_MINUS_EQUAL => true,
        T_MOD_EQUAL => true,
        T_MUL_EQUAL => true,
        T_OBJECT_CAST => true,
        T_OR_EQUAL => true,
        T_PLUS_EQUAL => true,
        T_REQUIRE => true,
        T_REQUIRE_ONCE => true,
        T_SL => true,
        T_SL_EQUAL => true,
        T_SR => true,
        T_SR_EQUAL => true,
        T_STRING_CAST => true,
        T_UNSET_CAST => true,
        T_XOR_EQUAL => true,
        T_POW => true,
        T_POW_EQUAL => true,
        T_SPACESHIP => true,
        T_DOUBLE_ARROW => true,
        T_FN => true,
        T_COALESCE_EQUAL => true,
        '!' => true,
        '%' => true,
        '&' => true,
        '*' => true,
        '+' => true,
        '-' => true,
        '.' => true,
        '/' => true,
        ':' => true,
        '<' => true,
        '=' => true,
        '>' => true,
        '?' => true,
        '^' => true,
        '|' => true,
        '~' => true,
    ];

    private static array $preserve_spaces = [
        T_CLASS => true,
        T_NEW => true,
    ];

    private static array $strip = [
        '(' => true,
        ')' => true,
        '[' => true,
        ']' => true,
        '{' => true,
        '}' => true,
        T_OBJECT_OPERATOR => true,
        T_DOUBLE_COLON => true,
        T_NS_SEPARATOR => true,
    ];

    private static array $classcalls = [
        T_DOUBLE_COLON => true,
        T_OBJECT_OPERATOR => true,
    ];

    private static array $namespace = [
        T_STRING => true,
    ];

    /**
     * @psalm-param callable-array|callable-string $function
     *
     * @psalm-return list<array{parameters: list<CallParameter>, modifiers: list<PhpToken>}>
     *
     * @return array List of matching calls on the relevant line
     */
    public static function getFunctionCalls(string $source, int $line, $function): array
    {
        static $up = [
            '(' => true,
            '[' => true,
            '{' => true,
            T_CURLY_OPEN => true,
            T_DOLLAR_OPEN_CURLY_BRACES => true,
        ];
        static $down = [
            ')' => true,
            ']' => true,
            '}' => true,
        ];
        static $modifiers = [
            '!' => true,
            '@' => true,
            '~' => true,
            '+' => true,
            '-' => true,
        ];
        static $identifier = [
            T_DOUBLE_COLON => true,
            T_STRING => true,
            T_NS_SEPARATOR => true,
        ];

        if (KINT_PHP80) {
            $up[T_ATTRIBUTE] = true;
            self::$operator[T_MATCH] = true;
            self::$strip[T_NULLSAFE_OBJECT_OPERATOR] = true;
            self::$classcalls[T_NULLSAFE_OBJECT_OPERATOR] = true;
            self::$namespace[T_NAME_FULLY_QUALIFIED] = true;
            self::$namespace[T_NAME_QUALIFIED] = true;
            self::$namespace[T_NAME_RELATIVE] = true;
            $identifier[T_NAME_FULLY_QUALIFIED] = true;
            $identifier[T_NAME_QUALIFIED] = true;
            $identifier[T_NAME_RELATIVE] = true;
        }

        if (!KINT_PHP84) {
            self::$operator[T_NEW] = true; // @codeCoverageIgnore
        }

        /** @psalm-var list<PhpToken> */
        $tokens = \token_get_all($source);
        $function_calls = [];

        // Performance optimization preventing backwards loops
        /** @psalm-var array<PhpToken|null> */
        $prev_tokens = [null, null, null];

        if (\is_array($function)) {
            $class = \explode('\\', $function[0]);
            $class = \strtolower(\end($class));
            $function = \strtolower($function[1]);
        } else {
            $class = null;
            /**
             * @psalm-suppress RedundantFunctionCallGivenDocblockType
             * Psalm bug #11075
             */
            $function = \strtolower($function);
        }

        // Loop through tokens
        foreach ($tokens as $index => $token) {
            if (!\is_array($token)) {
                continue;
            }

            if ($token[2] > $line) {
                break;
            }

            // Store the last real tokens for later
            if (isset(self::$ignore[$token[0]])) {
                continue;
            }

            $prev_tokens = [$prev_tokens[1], $prev_tokens[2], $token];

            // The logic for 7.3 through 8.1 is far more complicated.
            // This should speed things up without making a lot more work for us
            if (KINT_PHP82 && $line !== $token[2]) {
                continue;
            }

            // Check if it's the right type to be the function we're looking for
            if (!isset(self::$namespace[$token[0]])) {
                continue;
            }

            $ns = \explode('\\', \strtolower($token[1]));

            if (\end($ns) !== $function) {
                continue;
            }

            // Check if it's a function call
            $nextReal = self::realTokenIndex($tokens, $index);
            if ('(' !== ($tokens[$nextReal] ?? null)) {
                continue;
            }

            // Check if it matches the signature
            if (null === $class) {
                if (null !== $prev_tokens[1] && isset(self::$classcalls[$prev_tokens[1][0]])) {
                    continue;
                }
            } else {
                if (null === $prev_tokens[1] || T_DOUBLE_COLON !== $prev_tokens[1][0]) {
                    continue;
                }

                if (null === $prev_tokens[0] || !isset(self::$namespace[$prev_tokens[0][0]])) {
                    continue;
                }

                // All self::$namespace tokens are T_ constants
                /**
                 * @psalm-var PhpTokenArray $prev_tokens[0]
                 * Psalm bug #746 (wontfix)
                 */
                $ns = \explode('\\', \strtolower($prev_tokens[0][1]));

                if (\end($ns) !== $class) {
                    continue;
                }
            }

            $last_line = $token[2];
            $depth = 1; // The depth respective to the function call
            $offset = $nextReal + 1; // The start of the function call
            $instring = false; // Whether we're in a string or not
            $realtokens = false; // Whether the current scope contains anything meaningful or not
            $paramrealtokens = false; // Whether the current parameter contains anything meaningful
            $params = []; // All our collected parameters
            $shortparam = []; // The short version of the parameter
            $param_start = $offset; // The distance to the start of the parameter

            // Loop through the following tokens until the function call ends
            while (isset($tokens[$offset])) {
                $token = $tokens[$offset];

                if (\is_array($token)) {
                    $last_line = $token[2];
                }

                if (!isset(self::$ignore[$token[0]]) && !isset($down[$token[0]])) {
                    $paramrealtokens = $realtokens = true;
                }

                // If it's a token that makes us to up a level, increase the depth
                if (isset($up[$token[0]])) {
                    if (1 === $depth) {
                        $shortparam[] = $token;
                        $realtokens = false;
                    }

                    ++$depth;
                } elseif (isset($down[$token[0]])) {
                    --$depth;

                    // If this brings us down to the parameter level, and we've had
                    // real tokens since going up, fill the $shortparam with an ellipsis
                    if (1 === $depth) {
                        if ($realtokens) {
                            $shortparam[] = '...';
                        }
                        $shortparam[] = $token;
                    }
                } elseif ('"' === $token || 'b"' === $token) {
                    // Strings use the same symbol for up and down, but we can
                    // only ever be inside one string, so just use a bool for that
                    if ($instring) {
                        --$depth;
                        if (1 === $depth) {
                            $shortparam[] = '...';
                        }
                    } else {
                        ++$depth;
                    }

                    $instring = !$instring;

                    $shortparam[] = $token;
                } elseif (1 === $depth) {
                    if (',' === $token[0]) {
                        $params[] = [
                            'full' => \array_slice($tokens, $param_start, $offset - $param_start),
                            'short' => $shortparam,
                        ];
                        $shortparam = [];
                        $paramrealtokens = false;
                        $param_start = $offset + 1;
                    } elseif (T_CONSTANT_ENCAPSED_STRING === $token[0]) {
                        $quote = $token[1][0];
                        if ('b' === $quote) {
                            $quote = $token[1][1];
                            if (\strlen($token[1]) > 3) {
                                $token[1] = 'b'.$quote.'...'.$quote;
                            }
                        } else {
                            if (\strlen($token[1]) > 2) {
                                $token[1] = $quote.'...'.$quote;
                            }
                        }
                        $shortparam[] = $token;
                    } else {
                        $shortparam[] = $token;
                    }
                }

                // Depth has dropped to 0 (So we've hit the closing paren)
                if ($depth <= 0) {
                    if ($paramrealtokens) {
                        $params[] = [
                            'full' => \array_slice($tokens, $param_start, $offset - $param_start),
                            'short' => $shortparam,
                        ];
                    }

                    break;
                }

                ++$offset;
            }

            // If we're not passed (or at) the line at the end
            // of the function call, we're too early so skip it
            // Only applies to < 8.2 since we check line explicitly above that
            if (!KINT_PHP82 && $last_line < $line) {
                continue; // @codeCoverageIgnore
            }

            $formatted_parameters = [];

            // Format the final output parameters
            foreach ($params as $param) {
                $name = self::tokensFormatted($param['short']);
                $path = self::tokensToString(self::tokensTrim($param['full']));
                $expression = false;
                $literal = false;
                $new_without_parens = false;

                foreach ($name as $token) {
                    if (self::tokenIsOperator($token)) {
                        $expression = true;
                        break;
                    }
                }

                // As of 8.4 new is only an expression when parentheses are
                // omitted. In that case we can cheat and add them ourselves.
                //
                // > PHP interprets the first expression after new as a class name
                // per https://wiki.php.net/rfc/new_without_parentheses
                if (KINT_PHP84 && !$expression && T_NEW === $name[0][0]) {
                    $had_name_token = false;
                    $new_without_parens = true;

                    foreach ($name as $token) {
                        if (T_NEW === $token[0]) {
                            continue;
                        }

                        if (isset(self::$ignore[$token[0]])) {
                            continue;
                        }

                        if (T_CLASS === $token[0]) {
                            $new_without_parens = false;
                            break;
                        }

                        if ('(' === $token && $had_name_token) {
                            $new_without_parens = false;
                            break;
                        }

                        $had_name_token = true;
                    }
                }

                if (!$expression && 1 === \count($name)) {
                    switch ($name[0][0]) {
                        case T_CONSTANT_ENCAPSED_STRING:
                        case T_LNUMBER:
                        case T_DNUMBER:
                            $literal = true;
                            break;
                        case T_STRING:
                            switch (\strtolower($name[0][1])) {
                                case 'null':
                                case 'true':
                                case 'false':
                                    $literal = true;
                            }
                    }

                    $name = self::tokensToString($name);
                } else {
                    $name = self::tokensToString($name);

                    if (!$expression) {
                        switch (\strtolower($name)) {
                            case 'array()':
                            case '[]':
                                $literal = true;
                                break;
                        }
                    }
                }

                $formatted_parameters[] = [
                    'name' => $name,
                    'path' => $path,
                    'expression' => $expression,
                    'literal' => $literal,
                    'new_without_parens' => $new_without_parens,
                ];
            }

            // Skip first-class callables
            if (KINT_PHP81 && 1 === \count($formatted_parameters) && '...' === \reset($formatted_parameters)['path']) {
                continue;
            }

            // Get the modifiers
            --$index;

            while (isset($tokens[$index])) {
                if (!isset(self::$ignore[$tokens[$index][0]]) && !isset($identifier[$tokens[$index][0]])) {
                    break;
                }

                --$index;
            }

            $mods = [];

            while (isset($tokens[$index])) {
                if (isset(self::$ignore[$tokens[$index][0]])) {
                    --$index;
                    continue;
                }

                if (isset($modifiers[$tokens[$index][0]])) {
                    $mods[] = $tokens[$index];
                    --$index;
                    continue;
                }

                break;
            }

            $function_calls[] = [
                'parameters' => $formatted_parameters,
                'modifiers' => $mods,
            ];
        }

        return $function_calls;
    }

    private static function realTokenIndex(array $tokens, int $index): ?int
    {
        ++$index;

        while (isset($tokens[$index])) {
            if (!isset(self::$ignore[$tokens[$index][0]])) {
                return $index;
            }

            ++$index;
        }

        return null;
    }

    /**
     * We need a separate method to check if tokens are operators because we
     * occasionally add "..." to short parameter versions. If we simply check
     * for `$token[0]` then "..." will incorrectly match the "." operator.
     *
     * @psalm-param PhpToken $token The token to check
     */
    private static function tokenIsOperator($token): bool
    {
        return '...' !== $token && isset(self::$operator[$token[0]]);
    }

    /**
     * @psalm-param PhpToken $token The token to check
     */
    private static function tokenPreserveWhitespace($token): bool
    {
        return self::tokenIsOperator($token) || isset(self::$preserve_spaces[$token[0]]);
    }

    private static function tokensToString(array $tokens): string
    {
        $out = '';

        foreach ($tokens as $token) {
            if (\is_string($token)) {
                $out .= $token;
            } else {
                $out .= $token[1];
            }
        }

        return $out;
    }

    private static function tokensTrim(array $tokens): array
    {
        foreach ($tokens as $index => $token) {
            if (isset(self::$ignore[$token[0]])) {
                unset($tokens[$index]);
            } else {
                break;
            }
        }

        $tokens = \array_reverse($tokens);

        foreach ($tokens as $index => $token) {
            if (isset(self::$ignore[$token[0]])) {
                unset($tokens[$index]);
            } else {
                break;
            }
        }

        return \array_reverse($tokens);
    }

    private static function tokensFormatted(array $tokens): array
    {
        $tokens = self::tokensTrim($tokens);

        $space = false;
        $attribute = false;
        // Keep space between "strip" symbols for different behavior for matches or closures
        // Normally we want to strip spaces between strip tokens: $x{...}[...]
        // However with closures and matches we don't: function (...) {...}
        $ignorestrip = false;
        $output = [];
        $last = null;

        if (T_FUNCTION === $tokens[0][0] ||
            T_FN === $tokens[0][0] ||
            (KINT_PHP80 && T_MATCH === $tokens[0][0])
        ) {
            $ignorestrip = true;
        }

        foreach ($tokens as $index => $token) {
            if (isset(self::$ignore[$token[0]])) {
                if ($space) {
                    continue;
                }

                $next = self::realTokenIndex($tokens, $index);
                if (null === $next) {
                    // This should be impossible, since we always call tokensTrim first
                    break; // @codeCoverageIgnore
                }
                $next = $tokens[$next];

                /**
                 * @psalm-var PhpToken $last
                 * Since we call tokensTrim we know we can't be here without a $last
                 */
                if ($attribute && ']' === $last[0]) {
                    $attribute = false;
                } elseif (!$ignorestrip && isset(self::$strip[$last[0]]) && !self::tokenPreserveWhitespace($next)) {
                    continue;
                }

                if (!$ignorestrip && isset(self::$strip[$next[0]]) && !self::tokenPreserveWhitespace($last)) {
                    continue;
                }

                $token[1] = ' ';
                $space = true;
            } else {
                if (KINT_PHP80 && null !== $last && T_ATTRIBUTE === $last[0]) {
                    $attribute = true;
                }

                $space = false;
                $last = $token;
            }

            $output[] = $token;
        }

        return $output;
    }
}
