<?php declare(strict_types = 1);

namespace SlevomatCodingStandard\Sniffs\Commenting;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use SlevomatCodingStandard\Helpers\Annotation\VariableAnnotation;
use SlevomatCodingStandard\Helpers\AnnotationHelper;
use SlevomatCodingStandard\Helpers\FixerHelper;
use SlevomatCodingStandard\Helpers\PropertyHelper;
use SlevomatCodingStandard\Helpers\TokenHelper;
use function count;
use function in_array;
use function preg_match;
use function sprintf;
use function substr;
use const T_AS;
use const T_ATTRIBUTE;
use const T_CLOSURE;
use const T_COALESCE_EQUAL;
use const T_COMMENT;
use const T_CONST;
use const T_DOC_COMMENT_OPEN_TAG;
use const T_EQUAL;
use const T_FN;
use const T_FOREACH;
use const T_LIST;
use const T_OPEN_SHORT_ARRAY;
use const T_PRIVATE;
use const T_PROTECTED;
use const T_PUBLIC;
use const T_READONLY;
use const T_RETURN;
use const T_SEMICOLON;
use const T_STATIC;
use const T_VARIABLE;
use const T_WHILE;

class InlineDocCommentDeclarationSniff implements Sniff
{

	public const CODE_INVALID_FORMAT = 'InvalidFormat';
	public const CODE_INVALID_COMMENT_TYPE = 'InvalidCommentType';
	public const CODE_MISSING_VARIABLE = 'MissingVariable';
	public const CODE_NO_ASSIGNMENT = 'NoAssignment';

	/** @var bool */
	public $allowDocCommentAboveReturn = false;

	/** @var bool */
	public $allowAboveNonAssignment = false;

	/**
	 * @return array<int, (int|string)>
	 */
	public function register(): array
	{
		return [
			T_DOC_COMMENT_OPEN_TAG,
			T_COMMENT,
		];
	}

	/**
	 * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
	 * @param int $commentOpenPointer
	 */
	public function process(File $phpcsFile, $commentOpenPointer): void
	{
		$tokens = $phpcsFile->getTokens();

		$commentClosePointer = $tokens[$commentOpenPointer]['code'] === T_COMMENT
			? $commentOpenPointer
			: $tokens[$commentOpenPointer]['comment_closer'];

		$pointerAfterCommentClosePointer = TokenHelper::findNextEffective($phpcsFile, $commentClosePointer + 1);
		if ($pointerAfterCommentClosePointer !== null) {
			do {
				if ($tokens[$pointerAfterCommentClosePointer]['code'] !== T_ATTRIBUTE) {
					break;
				}

				$pointerAfterCommentClosePointer = TokenHelper::findNextEffective(
					$phpcsFile,
					$tokens[$pointerAfterCommentClosePointer]['attribute_closer'] + 1
				);
			} while (true);

			if (in_array(
				$tokens[$pointerAfterCommentClosePointer]['code'],
				[T_PRIVATE, T_PROTECTED, T_PUBLIC, T_READONLY, T_CONST],
				true
			)) {
				return;
			}

			if ($tokens[$pointerAfterCommentClosePointer]['code'] === T_STATIC) {
				$pointerAfterStatic = TokenHelper::findNextEffective($phpcsFile, $pointerAfterCommentClosePointer + 1);
				if (in_array($tokens[$pointerAfterStatic]['code'], [T_PRIVATE, T_PROTECTED, T_PUBLIC, T_READONLY], true)) {
					return;
				}

				if ($tokens[$pointerAfterStatic]['code'] === T_VARIABLE && PropertyHelper::isProperty($phpcsFile, $pointerAfterStatic)) {
					return;
				}
			}
		}

		if ($tokens[$commentOpenPointer]['code'] === T_COMMENT) {
			$this->checkCommentType($phpcsFile, $commentOpenPointer);
			return;
		}

		/** @var list<VariableAnnotation> $annotations */
		$annotations = AnnotationHelper::getAnnotationsByName($phpcsFile, $commentOpenPointer, '@var');

		if ($annotations === []) {
			return;
		}

		if ($this->allowDocCommentAboveReturn) {
			$pointerAfterCommentClosePointer = TokenHelper::findNextEffective($phpcsFile, $commentClosePointer + 1);
			if ($tokens[$pointerAfterCommentClosePointer]['code'] === T_RETURN) {
				return;
			}
		}

		$this->checkFormat($phpcsFile, $annotations);
		$this->checkVariable($phpcsFile, $annotations, $commentOpenPointer, $commentClosePointer);
	}

	private function checkCommentType(File $phpcsFile, int $commentOpenPointer): void
	{
		$tokens = $phpcsFile->getTokens();

		if (preg_match('~^/\*\\s*@var\\s+~', $tokens[$commentOpenPointer]['content']) === 0) {
			return;
		}

		$fix = $phpcsFile->addFixableError(
			'Invalid comment type /* */ for inline documentation comment, use /** */.',
			$commentOpenPointer,
			self::CODE_INVALID_COMMENT_TYPE
		);

		if (!$fix) {
			return;
		}

		$phpcsFile->fixer->beginChangeset();
		$phpcsFile->fixer->replaceToken($commentOpenPointer, sprintf('/**%s', substr($tokens[$commentOpenPointer]['content'], 2)));
		$phpcsFile->fixer->endChangeset();
	}

	/**
	 * @param list<VariableAnnotation> $annotations
	 */
	private function checkFormat(File $phpcsFile, array $annotations): void
	{
		foreach ($annotations as $annotation) {
			if (!$annotation->isInvalid() && $annotation->getVariableName() !== null) {
				continue;
			}

			$annotationContent = $annotation->getContent();

			$variableName = '$variable';
			$type = null;

			if (
				$annotationContent !== null
				&& preg_match('~(\$\w+)(?:\s+(.+))?$~i', $annotation->getContent(), $matches) === 1
			) {
				$variableName = $matches[1];
				$type = $matches[2] ?? null;
			}

			// It may be description when it contains whitespaces
			$isFixable = $type !== null && preg_match('~\s~', $type) === 0;

			if (!$isFixable) {
				$phpcsFile->addError(
					sprintf(
						'Invalid inline documentation comment format "@var %1$s", expected "@var type %2$s Optional description".',
						$annotation->getContent(),
						$variableName
					),
					$annotation->getStartPointer(),
					self::CODE_INVALID_FORMAT
				);

				continue;
			}

			$fix = $phpcsFile->addFixableError(
				sprintf(
					'Invalid inline documentation comment format "@var %1$s", expected "@var %2$s %3$s".',
					$annotation->getContent(),
					$type,
					$variableName
				),
				$annotation->getStartPointer(),
				self::CODE_INVALID_FORMAT
			);

			if (!$fix) {
				continue;
			}

			$phpcsFile->fixer->beginChangeset();

			$phpcsFile->fixer->addContent(
				$annotation->getStartPointer(),
				sprintf(
					' %s %s ',
					$type,
					$variableName
				)
			);

			FixerHelper::removeBetweenIncluding($phpcsFile, $annotation->getStartPointer() + 1, $annotation->getEndPointer());

			$phpcsFile->fixer->endChangeset();
		}
	}

	/**
	 * @param list<VariableAnnotation> $annotations
	 */
	private function checkVariable(File $phpcsFile, array $annotations, int $docCommentOpenerPointer, int $docCommentCloserPointer): void
	{
		$tokens = $phpcsFile->getTokens();

		$checkedTokens = [T_VARIABLE, T_FOREACH, T_WHILE, T_LIST, T_OPEN_SHORT_ARRAY, T_CLOSURE, T_FN];

		$variableNames = [];

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

			$variableName = $variableAnnotation->getVariableName();
			if ($variableName === null) {
				continue;
			}

			$variableNames[] = $variableName;
		}

		$improveCodePointer = function (int $codePointer) use ($phpcsFile, $tokens, $checkedTokens, $variableNames): int {
			$shouldSearchClosure = false;

			if (!in_array($tokens[$codePointer]['code'], $checkedTokens, true)) {
				$shouldSearchClosure = true;
			} elseif (
				$tokens[$codePointer]['code'] === T_VARIABLE
				&& (
					!$this->isAssignment($phpcsFile, $codePointer)
					|| !in_array($tokens[$codePointer]['content'], $variableNames, true)
				)
			) {
				$shouldSearchClosure = true;
			}

			if (!$shouldSearchClosure) {
				return $codePointer;
			}

			$closurePointer = TokenHelper::findNext($phpcsFile, [T_CLOSURE, T_FN], $codePointer + 1);
			if ($closurePointer !== null && $tokens[$codePointer]['line'] === $tokens[$closurePointer]['line']) {
				return $closurePointer;
			}

			return $codePointer;
		};

		$firstPointerOnNextLine = TokenHelper::findFirstNonWhitespaceOnNextLine($phpcsFile, $docCommentCloserPointer);

		$codePointerAfter = $firstPointerOnNextLine;
		while ($codePointerAfter !== null && $tokens[$codePointerAfter]['code'] === T_DOC_COMMENT_OPEN_TAG) {
			$codePointerAfter = TokenHelper::findFirstNonWhitespaceOnNextLine($phpcsFile, $codePointerAfter + 1);
		}

		if ($codePointerAfter !== null) {
			if ($tokens[$codePointerAfter]['code'] === T_STATIC) {
				$codePointerAfter = TokenHelper::findNextEffective($phpcsFile, $codePointerAfter + 1);
			}

			$codePointerAfter = $improveCodePointer($codePointerAfter);
		}

		$codePointerBefore = TokenHelper::findFirstNonWhitespaceOnPreviousLine($phpcsFile, $docCommentOpenerPointer);
		while ($codePointerBefore !== null && $tokens[$codePointerBefore]['code'] === T_DOC_COMMENT_OPEN_TAG) {
			$codePointerBefore = TokenHelper::findFirstNonWhitespaceOnPreviousLine($phpcsFile, $codePointerBefore - 1);
		}

		if ($codePointerBefore !== null) {
			$codePointerBefore = $improveCodePointer($codePointerBefore);
		}

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

			$variableName = $variableAnnotation->getVariableName();
			if ($variableName === null) {
				continue;
			}

			$missingVariableErrorParameters = [
				sprintf('Missing variable %s before or after the documentation comment.', $variableName),
				$docCommentOpenerPointer,
				self::CODE_MISSING_VARIABLE,
			];

			$noAssignmentErrorParameters = [
				sprintf('No assignment to %s variable before or after the documentation comment.', $variableName),
				$docCommentOpenerPointer,
				self::CODE_NO_ASSIGNMENT,
			];

			if ($this->allowAboveNonAssignment && $firstPointerOnNextLine !== null) {
				for ($i = $firstPointerOnNextLine; $i < count($tokens); $i++) {
					if ($tokens[$i]['line'] > $tokens[$firstPointerOnNextLine]['line']) {
						break;
					}

					if ($tokens[$i]['code'] !== T_VARIABLE) {
						continue;
					}

					if ($tokens[$i]['content'] === $variableName) {
						return;
					}
				}
			}

			foreach ([1 => $codePointerBefore, 2 => $codePointerAfter] as $tryNo => $codePointer) {
				if ($codePointer === null || !in_array($tokens[$codePointer]['code'], $checkedTokens, true)) {
					if ($tryNo === 2) {
						$phpcsFile->addError(...$missingVariableErrorParameters);
					}

					continue;
				}

				if ($tokens[$codePointer]['code'] === T_VARIABLE) {
					if ($tokens[$codePointer]['content'] !== '$this' && !$this->isAssignment($phpcsFile, $codePointer)) {
						if ($tryNo === 2) {
							$phpcsFile->addError(...$noAssignmentErrorParameters);
						}

						continue;
					}

					if ($variableName !== $tokens[$codePointer]['content']) {
						if ($tryNo === 2) {
							$phpcsFile->addError(...$missingVariableErrorParameters);
						}

						continue;
					}
				} elseif ($tokens[$codePointer]['code'] === T_LIST) {
					$listParenthesisOpener = TokenHelper::findNextEffective($phpcsFile, $codePointer + 1);

					$variablePointerInList = TokenHelper::findNextContent(
						$phpcsFile,
						T_VARIABLE,
						$variableName,
						$listParenthesisOpener + 1,
						$tokens[$listParenthesisOpener]['parenthesis_closer']
					);
					if ($variablePointerInList === null) {
						if ($tryNo === 2) {
							$phpcsFile->addError(...$missingVariableErrorParameters);
						}

						continue;
					}

				} elseif ($tokens[$codePointer]['code'] === T_OPEN_SHORT_ARRAY) {
					$pointerAfterList = TokenHelper::findNextEffective($phpcsFile, $tokens[$codePointer]['bracket_closer'] + 1);
					if ($tokens[$pointerAfterList]['code'] !== T_EQUAL) {
						if ($tryNo === 2) {
							$phpcsFile->addError(...$noAssignmentErrorParameters);
						}

						continue;
					}

					$variablePointerInList = TokenHelper::findNextContent(
						$phpcsFile,
						T_VARIABLE,
						$variableName,
						$codePointer + 1,
						$tokens[$codePointer]['bracket_closer']
					);
					if ($variablePointerInList === null) {
						if ($tryNo === 2) {
							$phpcsFile->addError(...$missingVariableErrorParameters);
						}

						continue;
					}

				} elseif (in_array($tokens[$codePointer]['code'], [T_CLOSURE, T_FN], true)) {
					$parameterPointer = TokenHelper::findNextContent(
						$phpcsFile,
						T_VARIABLE,
						$variableName,
						$tokens[$codePointer]['parenthesis_opener'] + 1,
						$tokens[$codePointer]['parenthesis_closer']
					);
					if ($parameterPointer === null) {
						if ($tryNo === 2) {
							$phpcsFile->addError(...$missingVariableErrorParameters);
						}

						continue;
					}

				} else {
					if ($tokens[$codePointer]['code'] === T_WHILE) {
						$variablePointerInWhile = TokenHelper::findNextContent(
							$phpcsFile,
							T_VARIABLE,
							$variableName,
							$tokens[$codePointer]['parenthesis_opener'] + 1,
							$tokens[$codePointer]['parenthesis_closer']
						);
						if ($variablePointerInWhile === null) {
							if ($tryNo === 2) {
								$phpcsFile->addError(...$missingVariableErrorParameters);
							}

							continue;
						}

						$pointerAfterVariableInWhile = TokenHelper::findNextEffective($phpcsFile, $variablePointerInWhile + 1);
						if ($tokens[$pointerAfterVariableInWhile]['code'] !== T_EQUAL) {
							if ($tryNo === 2) {
								$phpcsFile->addError(...$noAssignmentErrorParameters);
							}

							continue;
						}
					} else {
						$asPointer = TokenHelper::findNext(
							$phpcsFile,
							T_AS,
							$tokens[$codePointer]['parenthesis_opener'] + 1,
							$tokens[$codePointer]['parenthesis_closer']
						);
						$variablePointerInForeach = TokenHelper::findNextContent(
							$phpcsFile,
							T_VARIABLE,
							$variableName,
							$asPointer + 1,
							$tokens[$codePointer]['parenthesis_closer']
						);
						if ($variablePointerInForeach === null) {
							if ($tryNo === 2) {
								$phpcsFile->addError(...$missingVariableErrorParameters);
							}

							continue;
						}
					}
				}

				// No error, don't check second $codePointer
				continue 2;
			}
		}
	}

	private function isAssignment(File $phpcsFile, int $pointer): bool
	{
		$tokens = $phpcsFile->getTokens();

		$pointerAfterVariable = TokenHelper::findNextEffective($phpcsFile, $pointer + 1);
		if ($tokens[$pointerAfterVariable]['code'] === T_SEMICOLON) {
			$pointerBeforeVariable = TokenHelper::findPreviousEffective($phpcsFile, $pointer - 1);
			return $tokens[$pointerBeforeVariable]['code'] === T_STATIC;
		}

		return in_array($tokens[$pointerAfterVariable]['code'], [T_EQUAL, T_COALESCE_EQUAL], true);
	}

}
