<?php declare(strict_types = 1);

namespace SlevomatCodingStandard\Helpers;

use Generator;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\TypelessParamTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
use function array_filter;
use function array_map;
use function array_pop;
use function array_reverse;
use function array_values;
use function in_array;
use function iterator_to_array;
use function preg_match;
use function preg_replace;
use function sprintf;
use function substr_count;
use const T_ANON_CLASS;
use const T_BITWISE_AND;
use const T_CLASS;
use const T_CLOSURE;
use const T_COLON;
use const T_ELLIPSIS;
use const T_ENUM;
use const T_FUNCTION;
use const T_INTERFACE;
use const T_NULLABLE;
use const T_RETURN;
use const T_SEMICOLON;
use const T_STRING;
use const T_TRAIT;
use const T_USE;
use const T_VARIABLE;
use const T_WHITESPACE;
use const T_YIELD;
use const T_YIELD_FROM;

/**
 * @internal
 */
class FunctionHelper
{

	public const LINE_INCLUDE_COMMENT = 1;
	public const LINE_INCLUDE_WHITESPACE = 2;

	public const SPECIAL_FUNCTIONS = [
		'array_key_exists',
		'array_slice',
		'assert',
		'boolval',
		'call_user_func',
		'call_user_func_array',
		'chr',
		'constant',
		'count',
		'define',
		'defined',
		'dirname',
		'doubleval',
		'extension_loaded',
		'floatval',
		'func_get_args',
		'func_num_args',
		'function_exists',
		'get_called_class',
		'get_class',
		'gettype',
		'in_array',
		'ini_get',
		'intval',
		'is_array',
		'is_bool',
		'is_callable',
		'is_double',
		'is_float',
		'is_int',
		'is_integer',
		'is_long',
		'is_null',
		'is_object',
		'is_real',
		'is_resource',
		'is_scalar',
		'is_string',
		'ord',
		'sizeof',
		'sprintf',
		'strlen',
		'strval',
	];

	public static function getTypeLabel(File $phpcsFile, int $functionPointer): string
	{
		return self::isMethod($phpcsFile, $functionPointer) ? 'Method' : 'Function';
	}

	public static function getName(File $phpcsFile, int $functionPointer): string
	{
		$tokens = $phpcsFile->getTokens();
		return $tokens[TokenHelper::findNext(
			$phpcsFile,
			T_STRING,
			$functionPointer + 1,
			$tokens[$functionPointer]['parenthesis_opener'],
		)]['content'];
	}

	public static function getFullyQualifiedName(File $phpcsFile, int $functionPointer): string
	{
		$name = self::getName($phpcsFile, $functionPointer);
		$namespace = NamespaceHelper::findCurrentNamespaceName($phpcsFile, $functionPointer);

		if (self::isMethod($phpcsFile, $functionPointer)) {
			foreach (array_reverse(
				$phpcsFile->getTokens()[$functionPointer]['conditions'],
				true,
			) as $conditionPointer => $conditionTokenCode) {
				if ($conditionTokenCode === T_ANON_CLASS) {
					return sprintf('class@anonymous::%s', $name);
				}

				if (in_array($conditionTokenCode, [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true)) {
					$name = sprintf(
						'%s%s::%s',
						NamespaceHelper::NAMESPACE_SEPARATOR,
						ClassHelper::getName($phpcsFile, $conditionPointer),
						$name,
					);
					break;
				}
			}

			return $namespace !== null ? sprintf('%s%s%s', NamespaceHelper::NAMESPACE_SEPARATOR, $namespace, $name) : $name;
		}

		return $namespace !== null
			? sprintf('%s%s%s%s', NamespaceHelper::NAMESPACE_SEPARATOR, $namespace, NamespaceHelper::NAMESPACE_SEPARATOR, $name)
			: $name;
	}

	public static function isAbstract(File $phpcsFile, int $functionPointer): bool
	{
		return !isset($phpcsFile->getTokens()[$functionPointer]['scope_opener']);
	}

	public static function isMethod(File $phpcsFile, int $functionPointer): bool
	{
		$functionPointerConditions = $phpcsFile->getTokens()[$functionPointer]['conditions'];
		if ($functionPointerConditions === []) {
			return false;
		}
		$lastFunctionPointerCondition = array_pop($functionPointerConditions);
		return in_array($lastFunctionPointerCondition, Tokens::$ooScopeTokens, true);
	}

	public static function findClassPointer(File $phpcsFile, int $functionPointer): ?int
	{
		$tokens = $phpcsFile->getTokens();

		if ($tokens[$functionPointer]['code'] === T_CLOSURE) {
			return null;
		}

		foreach (array_reverse($tokens[$functionPointer]['conditions'], true) as $conditionPointer => $conditionTokenCode) {
			if (!in_array($conditionTokenCode, Tokens::$ooScopeTokens, true)) {
				continue;
			}

			return $conditionPointer;
		}

		return null;
	}

	/**
	 * @return list<string>
	 */
	public static function getParametersNames(File $phpcsFile, int $functionPointer): array
	{
		$tokens = $phpcsFile->getTokens();

		$parametersNames = [];
		for ($i = $tokens[$functionPointer]['parenthesis_opener'] + 1; $i < $tokens[$functionPointer]['parenthesis_closer']; $i++) {
			if ($tokens[$i]['code'] !== T_VARIABLE) {
				continue;
			}

			$parametersNames[] = $tokens[$i]['content'];
		}

		return $parametersNames;
	}

	/**
	 * @return array<string, TypeHint|null>
	 */
	public static function getParametersTypeHints(File $phpcsFile, int $functionPointer): array
	{
		$tokens = $phpcsFile->getTokens();

		$parametersTypeHints = [];
		for ($i = $tokens[$functionPointer]['parenthesis_opener'] + 1; $i < $tokens[$functionPointer]['parenthesis_closer']; $i++) {
			if ($tokens[$i]['code'] !== T_VARIABLE) {
				continue;
			}

			$parameterName = $tokens[$i]['content'];

			$pointerBeforeVariable = TokenHelper::findPreviousExcluding(
				$phpcsFile,
				[...TokenHelper::INEFFECTIVE_TOKEN_CODES, T_BITWISE_AND, T_ELLIPSIS],
				$i - 1,
			);

			if (!in_array($tokens[$pointerBeforeVariable]['code'], TokenHelper::TYPE_HINT_TOKEN_CODES, true)) {
				$parametersTypeHints[$parameterName] = null;
				continue;
			}

			$typeHintEndPointer = $pointerBeforeVariable;
			$typeHintStartPointer = TypeHintHelper::getStartPointer($phpcsFile, $typeHintEndPointer);

			$pointerBeforeTypeHint = TokenHelper::findPreviousEffective($phpcsFile, $typeHintStartPointer - 1);
			$isNullable = $tokens[$pointerBeforeTypeHint]['code'] === T_NULLABLE;
			if ($isNullable) {
				$typeHintStartPointer = $pointerBeforeTypeHint;
			}

			$typeHint = TokenHelper::getContent($phpcsFile, $typeHintStartPointer, $typeHintEndPointer);

			/** @var string $typeHint */
			$typeHint = preg_replace('~\s+~', '', $typeHint);

			if (!$isNullable) {
				$isNullable = preg_match('~(?:^|\|)null(?:\||$)~i', $typeHint) === 1;
			}

			$parametersTypeHints[$parameterName] = new TypeHint($typeHint, $isNullable, $typeHintStartPointer, $typeHintEndPointer);
		}

		return $parametersTypeHints;
	}

	public static function returnsValue(File $phpcsFile, int $functionPointer): bool
	{
		$tokens = $phpcsFile->getTokens();

		$firstPointerInScope = $tokens[$functionPointer]['scope_opener'] + 1;

		for ($i = $firstPointerInScope; $i < $tokens[$functionPointer]['scope_closer']; $i++) {
			if (!in_array($tokens[$i]['code'], [T_YIELD, T_YIELD_FROM], true)) {
				continue;
			}

			if (!ScopeHelper::isInSameScope($phpcsFile, $i, $firstPointerInScope)) {
				continue;
			}

			return true;
		}

		for ($i = $firstPointerInScope; $i < $tokens[$functionPointer]['scope_closer']; $i++) {
			if ($tokens[$i]['code'] !== T_RETURN) {
				continue;
			}

			if (!ScopeHelper::isInSameScope($phpcsFile, $i, $firstPointerInScope)) {
				continue;
			}

			$nextEffectiveTokenPointer = TokenHelper::findNextEffective($phpcsFile, $i + 1);
			return $tokens[$nextEffectiveTokenPointer]['code'] !== T_SEMICOLON;
		}

		return false;
	}

	public static function findReturnTypeHint(File $phpcsFile, int $functionPointer): ?TypeHint
	{
		$tokens = $phpcsFile->getTokens();

		$nextPointer = TokenHelper::findNextEffective($phpcsFile, $tokens[$functionPointer]['parenthesis_closer'] + 1);

		if ($tokens[$nextPointer]['code'] === T_USE) {
			$useParenthesisOpener = TokenHelper::findNextEffective($phpcsFile, $nextPointer + 1);
			$colonPointer = TokenHelper::findNextEffective($phpcsFile, $tokens[$useParenthesisOpener]['parenthesis_closer'] + 1);
		} else {
			$colonPointer = $nextPointer;
		}

		if ($tokens[$colonPointer]['code'] !== T_COLON) {
			return null;
		}

		$typeHintStartPointer = TokenHelper::findNextEffective($phpcsFile, $colonPointer + 1);
		$nullable = $tokens[$typeHintStartPointer]['code'] === T_NULLABLE;

		$pointerAfterTypeHint = self::isAbstract($phpcsFile, $functionPointer)
			? TokenHelper::findNext($phpcsFile, T_SEMICOLON, $typeHintStartPointer + 1)
			: $tokens[$functionPointer]['scope_opener'];

		$typeHintEndPointer = TokenHelper::findPreviousEffective($phpcsFile, $pointerAfterTypeHint - 1);

		$typeHint = TokenHelper::getContent($phpcsFile, $typeHintStartPointer, $typeHintEndPointer);

		/** @var string $typeHint */
		$typeHint = preg_replace('~\s+~', '', $typeHint);

		if (!$nullable) {
			$nullable = preg_match('~(?:^|\|)null(?:\||$)~i', $typeHint) === 1;
		}

		return new TypeHint($typeHint, $nullable, $typeHintStartPointer, $typeHintEndPointer);
	}

	public static function hasReturnTypeHint(File $phpcsFile, int $functionPointer): bool
	{
		return self::findReturnTypeHint($phpcsFile, $functionPointer) !== null;
	}

	/**
	 * @return list<Annotation<ParamTagValueNode>|Annotation<TypelessParamTagValueNode>>
	 */
	public static function getParametersAnnotations(File $phpcsFile, int $functionPointer): array
	{
		return AnnotationHelper::getAnnotations($phpcsFile, $functionPointer, '@param');
	}

	/**
	 * @return array<string, Annotation<VarTagValueNode>|Annotation<ParamTagValueNode>|Annotation<TypelessParamTagValueNode>>
	 */
	public static function getValidParametersAnnotations(File $phpcsFile, int $functionPointer): array
	{
		$tokens = $phpcsFile->getTokens();

		$parametersAnnotations = [];

		if (self::getName($phpcsFile, $functionPointer) === '__construct') {
			for ($i = $tokens[$functionPointer]['parenthesis_opener'] + 1; $i < $tokens[$functionPointer]['parenthesis_closer']; $i++) {
				if ($tokens[$i]['code'] !== T_VARIABLE) {
					continue;
				}

				$varAnnotations = AnnotationHelper::getAnnotations($phpcsFile, $i, '@var');
				if ($varAnnotations === []) {
					continue;
				}

				$parametersAnnotations[$tokens[$i]['content']] = $varAnnotations[0];
			}
		}

		foreach (self::getParametersAnnotations($phpcsFile, $functionPointer) as $parameterAnnotation) {
			if ($parameterAnnotation->isInvalid()) {
				continue;
			}

			$parametersAnnotations[$parameterAnnotation->getValue()->parameterName] = $parameterAnnotation;
		}

		return $parametersAnnotations;
	}

	/**
	 * @return array<string, Annotation<VarTagValueNode>|Annotation<ParamTagValueNode>>
	 */
	public static function getValidPrefixedParametersAnnotations(File $phpcsFile, int $functionPointer): array
	{
		$tokens = $phpcsFile->getTokens();

		$parametersAnnotations = [];
		foreach (AnnotationHelper::STATIC_ANALYSIS_PREFIXES as $prefix) {
			if (self::getName($phpcsFile, $functionPointer) === '__construct') {
				for ($i = $tokens[$functionPointer]['parenthesis_opener'] + 1; $i < $tokens[$functionPointer]['parenthesis_closer']; $i++) {
					if ($tokens[$i]['code'] !== T_VARIABLE) {
						continue;
					}

					/** @var list<Annotation<VarTagValueNode>> $varAnnotations */
					$varAnnotations = AnnotationHelper::getAnnotations($phpcsFile, $i, sprintf('@%s-var', $prefix));
					if ($varAnnotations === []) {
						continue;
					}

					$parametersAnnotations[$tokens[$i]['content']] = $varAnnotations[0];
				}
			}

			/** @var list<Annotation<ParamTagValueNode>> $annotations */
			$annotations = AnnotationHelper::getAnnotations($phpcsFile, $functionPointer, sprintf('@%s-param', $prefix));
			foreach ($annotations as $parameterAnnotation) {
				if ($parameterAnnotation->isInvalid()) {
					continue;
				}

				$parametersAnnotations[$parameterAnnotation->getValue()->parameterName] = $parameterAnnotation;
			}
		}

		return $parametersAnnotations;
	}

	/**
	 * @return Annotation<ReturnTagValueNode>|null
	 */
	public static function findReturnAnnotation(File $phpcsFile, int $functionPointer): ?Annotation
	{
		/** @var list<Annotation<ReturnTagValueNode>> $returnAnnotations */
		$returnAnnotations = AnnotationHelper::getAnnotations($phpcsFile, $functionPointer, '@return');

		if ($returnAnnotations === []) {
			return null;
		}

		return $returnAnnotations[0];
	}

	/**
	 * @return list<Annotation>
	 */
	public static function getValidPrefixedReturnAnnotations(File $phpcsFile, int $functionPointer): array
	{
		$returnAnnotations = [];

		$annotations = AnnotationHelper::getAnnotations($phpcsFile, $functionPointer);

		foreach (AnnotationHelper::STATIC_ANALYSIS_PREFIXES as $prefix) {
			$prefixedAnnotationName = sprintf('@%s-return', $prefix);

			foreach ($annotations as $annotation) {
				if ($annotation->isInvalid()) {
					continue;
				}

				if ($annotation->getName() === $prefixedAnnotationName) {
					$returnAnnotations[] = $annotation;
				}
			}
		}

		return $returnAnnotations;
	}

	/**
	 * @return list<string>
	 */
	public static function getAllFunctionNames(File $phpcsFile): array
	{
		$previousFunctionPointer = 0;

		return array_map(
			static fn (int $functionPointer): string => self::getName($phpcsFile, $functionPointer),
			array_values(array_filter(
				iterator_to_array(self::getAllFunctionOrMethodPointers($phpcsFile, $previousFunctionPointer)),
				static fn (int $functionOrMethodPointer): bool => !self::isMethod($phpcsFile, $functionOrMethodPointer),
			)),
		);
	}

	/**
	 * @param int $flags optional bitmask of self::LINE_INCLUDE_* constants
	 */
	public static function getFunctionLengthInLines(File $file, int $functionPosition, int $flags = 0): int
	{
		if (self::isAbstract($file, $functionPosition)) {
			return 0;
		}
		return self::getLineCount($file, $functionPosition, $flags);
	}

	public static function getLineCount(File $file, int $tokenPosition, int $flags = 0): int
	{
		$includeWhitespace = ($flags & self::LINE_INCLUDE_WHITESPACE) === self::LINE_INCLUDE_WHITESPACE;
		$includeComments = ($flags & self::LINE_INCLUDE_COMMENT) === self::LINE_INCLUDE_COMMENT;

		$tokens = $file->getTokens();
		$token = $tokens[$tokenPosition];

		$tokenOpenerPosition = $token['scope_opener'] ?? $tokenPosition;
		$tokenCloserPosition = $token['scope_closer'] ?? $file->numTokens - 1;
		$tokenOpenerLine = $tokens[$tokenOpenerPosition]['line'];
		$tokenCloserLine = $tokens[$tokenCloserPosition]['line'];

		$lineCount = 0;
		$lastCommentLine = null;
		$previousIncludedPosition = null;

		for ($position = $tokenOpenerPosition; $position <= $tokenCloserPosition - 1; $position++) {
			$token = $tokens[$position];
			if ($includeComments === false) {
				if (in_array($token['code'], Tokens::$commentTokens, true)) {
					if (
						$previousIncludedPosition !== null &&
						substr_count($token['content'], $file->eolChar) > 0 &&
						$token['line'] === $tokens[$previousIncludedPosition]['line']
					) {
						// Comment with linebreak starting on same line as included Token
						$lineCount++;
					}
					// Don't include comment
					$lastCommentLine = $token['line'];
					continue;
				}
				if (
					$previousIncludedPosition !== null &&
					$token['code'] === T_WHITESPACE &&
					$token['line'] === $lastCommentLine &&
					$token['line'] !== $tokens[$previousIncludedPosition]['line']
				) {
					// Whitespace after block comment... still on comment line...
					// Ignore along with the comment
					continue;
				}
			}
			if ($token['code'] === T_WHITESPACE) {
				$nextNonWhitespacePosition = $file->findNext(T_WHITESPACE, $position + 1, $tokenCloserPosition + 1, true);
				if (
					$includeWhitespace === false &&
					$token['column'] === 1 &&
					$nextNonWhitespacePosition !== false &&
					$tokens[$nextNonWhitespacePosition]['line'] !== $token['line']
				) {
					// This line is nothing but whitepace
					$position = $nextNonWhitespacePosition - 1;
					continue;
				}
				if ($previousIncludedPosition === $tokenOpenerPosition && $token['line'] === $tokenOpenerLine) {
					// Don't linclude line break after opening "{"
					// Unless there was code or an (included) comment following the "{"
					continue;
				}
			}
			if ($token['code'] !== T_WHITESPACE) {
				$previousIncludedPosition = $position;
			}
			$newLineFoundCount = substr_count($token['content'], $file->eolChar);
			$lineCount += $newLineFoundCount;
		}
		if ($tokens[$previousIncludedPosition]['line'] === $tokenCloserLine) {
			// There is code or comment on the closing "}" line...
			$lineCount++;
		}
		return $lineCount;
	}

	/**
	 * @return Generator<int>
	 */
	private static function getAllFunctionOrMethodPointers(File $phpcsFile, int &$previousFunctionPointer): Generator
	{
		do {
			$nextFunctionPointer = TokenHelper::findNext($phpcsFile, T_FUNCTION, $previousFunctionPointer + 1);
			if ($nextFunctionPointer === null) {
				break;
			}

			$previousFunctionPointer = $nextFunctionPointer;

			yield $nextFunctionPointer;
		} while (true);
	}

}
