<?php declare(strict_types = 1);

namespace SlevomatCodingStandard\Helpers;

use PHP_CodeSniffer\Files\File;
use PHPStan\PhpDocParser\Ast\AbstractNodeVisitor;
use PHPStan\PhpDocParser\Ast\Attribute;
use PHPStan\PhpDocParser\Ast\Node;
use PHPStan\PhpDocParser\Ast\NodeTraverser;
use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArgument;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\TypelessParamTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode;
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
use function in_array;
use function sprintf;
use function strtolower;

/**
 * @internal
 */
class AnnotationHelper
{

	public const STATIC_ANALYSIS_PREFIXES = ['psalm', 'phpstan'];

	/**
	 * @return list<Annotation>
	 */
	public static function getAnnotations(File $phpcsFile, int $pointer, ?string $name = null): array
	{
		$docCommentOpenPointer = DocCommentHelper::findDocCommentOpenPointer($phpcsFile, $pointer);
		if ($docCommentOpenPointer === null) {
			return [];
		}

		return SniffLocalCache::getAndSetIfNotCached(
			$phpcsFile,
			sprintf('annotations-%d-%s', $docCommentOpenPointer, $name ?? 'all'),
			static function () use ($phpcsFile, $docCommentOpenPointer, $name): array {
				$annotations = [];

				if ($name !== null) {
					foreach (self::getAnnotations($phpcsFile, $docCommentOpenPointer) as $annotation) {
						if ($annotation->getName() === $name) {
							$annotations[] = $annotation;
						}
					}
				} else {
					$parsedDocComment = DocCommentHelper::parseDocComment($phpcsFile, $docCommentOpenPointer);

					if ($parsedDocComment !== null) {
						foreach ($parsedDocComment->getNode()->getTags() as $node) {
							$annotationStartPointer = $parsedDocComment->getNodeStartPointer($phpcsFile, $node);
							$annotations[] = new Annotation(
								$node,
								$annotationStartPointer,
								$parsedDocComment->getNodeEndPointer($phpcsFile, $node, $annotationStartPointer)
							);
						}
					}
				}

				return $annotations;
			}
		);
	}

	/**
	 * @param class-string $type
	 * @return list<Node>
	 */
	public static function getAnnotationNodesByType(Node $node, string $type): array
	{
		static $visitor;
		static $traverser;

		if ($visitor === null) {
			$visitor = new class extends AbstractNodeVisitor {

				/** @var class-string */
				private $type;

				/** @var list<Node> */
				private $nodes = [];

				/** @var list<Node> */
				private $nodesToIgnore = [];

				/**
				 * @return Node|list<Node>|NodeTraverser::*|null
				 */
				public function enterNode(Node $node)
				{
					if ($this->type === IdentifierTypeNode::class) {
						if ($node instanceof ArrayShapeItemNode || $node instanceof ObjectShapeItemNode) {
							$this->nodesToIgnore[] = $node->keyName;
						} elseif ($node instanceof DoctrineArgument) {
							$this->nodesToIgnore[] = $node->key;
						}
					}

					if ($node instanceof $this->type && !in_array($node, $this->nodesToIgnore, true)) {
						$this->nodes[] = $node;
					}

					return null;
				}

				/**
				 * @param class-string $type
				 */
				public function setType(string $type): void
				{
					$this->type = $type;
				}

				public function clean(): void
				{
					$this->nodes = [];
					$this->nodesToIgnore = [];
				}

				/**
				 * @return list<Node>
				 */
				public function getNodes(): array
				{
					return $this->nodes;
				}

			};
		}

		if ($traverser === null) {
			$traverser = new NodeTraverser([$visitor]);
		}

		$visitor->setType($type);
		$visitor->clean();

		$traverser->traverse([$node]);

		return $visitor->getNodes();
	}

	public static function fixAnnotation(
		ParsedDocComment $parsedDocComment,
		Annotation $annotation,
		Node $nodeToFix,
		Node $fixedNode
	): string
	{
		$originalNode = $annotation->getNode();

		/** @var PhpDocNode $newPhpDocNode */
		$newPhpDocNode = PhpDocParserHelper::cloneNode($parsedDocComment->getNode());

		foreach ($newPhpDocNode->getTags() as $node) {
			if ($node->getAttribute(Attribute::ORIGINAL_NODE) === $originalNode) {
				self::changeAnnotationNode($node, $nodeToFix, $fixedNode);
				break;
			}
		}

		return PhpDocParserHelper::getPrinter()->printFormatPreserving(
			$newPhpDocNode,
			$parsedDocComment->getNode(),
			$parsedDocComment->getTokens()
		);
	}

	/**
	 * @param array<int, string> $traversableTypeHints
	 */
	public static function isAnnotationUseless(
		File $phpcsFile,
		int $functionPointer,
		?TypeHint $typeHint,
		Annotation $annotation,
		array $traversableTypeHints,
		bool $enableUnionTypeHint = false,
		bool $enableIntersectionTypeHint = false,
		bool $enableStandaloneNullTrueFalseTypeHints = false
	): bool
	{
		if ($annotation->isInvalid()) {
			return false;
		}

		if ($typeHint === null) {
			return false;
		}

		/** @var ParamTagValueNode|TypelessParamTagValueNode|ReturnTagValueNode|VarTagValueNode $annotationValue */
		$annotationValue = $annotation->getValue();

		if ($annotationValue->description !== '') {
			return false;
		}

		if ($annotationValue instanceof TypelessParamTagValueNode) {
			return true;
		}

		$annotationType = $annotationValue->type;

		if (
			TypeHintHelper::isTraversableType(
				TypeHintHelper::getFullyQualifiedTypeHint($phpcsFile, $functionPointer, $typeHint->getTypeHintWithoutNullabilitySymbol()),
				$traversableTypeHints
			)
			&& !(
				$annotationType instanceof IdentifierTypeNode
				&& TypeHintHelper::isSimpleIterableTypeHint(strtolower($annotationType->name))
			)
		) {
			return false;
		}

		if (AnnotationTypeHelper::containsStaticOrThisType($annotationType)) {
			return false;
		}

		if (
			AnnotationTypeHelper::containsJustTwoTypes($annotationType)
			|| (
				$enableUnionTypeHint
				&& (
					$annotationType instanceof UnionTypeNode
					|| (
						$annotationType instanceof IdentifierTypeNode
						&& TypeHintHelper::isUnofficialUnionTypeHint($annotationType->name)
					)
				)
			)
			|| (
				$enableIntersectionTypeHint
				&& $annotationType instanceof IntersectionTypeNode
			)
		) {
			$annotationTypeHint = AnnotationTypeHelper::print($annotationType);
			return TypeHintHelper::typeHintEqualsAnnotation(
				$phpcsFile,
				$functionPointer,
				$typeHint->getTypeHint(),
				$annotationTypeHint
			);
		}

		if ($annotationType instanceof ObjectShapeNode) {
			return false;
		}

		if ($annotationType instanceof ConstTypeNode) {
			return false;
		}

		if ($annotationType instanceof GenericTypeNode) {
			return false;
		}

		if ($annotationType instanceof CallableTypeNode) {
			return false;
		}

		if ($annotationType instanceof ConditionalTypeNode) {
			return false;
		}

		if ($annotationType instanceof ConditionalTypeForParameterNode) {
			return false;
		}

		if ($annotationType instanceof IdentifierTypeNode) {
			if (in_array(
				strtolower($annotationType->name),
				['true', 'false', 'null'],
				true
			)) {
				return $enableStandaloneNullTrueFalseTypeHints;
			}

			if (in_array(
				strtolower($annotationType->name),
				['class-string', 'trait-string', 'callable-string', 'numeric-string', 'non-empty-string', 'non-falsy-string', 'literal-string', 'positive-int', 'negative-int'],
				true
			)) {
				return false;
			}
		}

		$annotationTypeHint = AnnotationTypeHelper::getTypeHintFromOneType($annotationType);
		return TypeHintHelper::typeHintEqualsAnnotation(
			$phpcsFile,
			$functionPointer,
			$typeHint->getTypeHintWithoutNullabilitySymbol(),
			$annotationTypeHint
		);
	}

	private static function changeAnnotationNode(PhpDocTagNode $tagNode, Node $nodeToChange, Node $changedNode): PhpDocTagNode
	{
		static $visitor;
		static $traverser;

		if ($visitor === null) {
			$visitor = new class extends AbstractNodeVisitor {

				/** @var Node */
				private $nodeToChange;

				/** @var Node */
				private $changedNode;

				/**
				 * @return Node|list<Node>|NodeTraverser::*|null
				 */
				public function enterNode(Node $node)
				{
					if ($node->getAttribute(Attribute::ORIGINAL_NODE) === $this->nodeToChange) {
						return $this->changedNode;
					}

					return null;
				}

				public function setNodeToChange(Node $nodeToChange): void
				{
					$this->nodeToChange = $nodeToChange;
				}

				public function setChangedNode(Node $changedNode): void
				{
					$this->changedNode = $changedNode;
				}

			};
		}

		if ($traverser === null) {
			$traverser = new NodeTraverser([$visitor]);
		}

		$visitor->setNodeToChange($nodeToChange);
		$visitor->setChangedNode($changedNode);

		[$changedTagNode] = $traverser->traverse([$tagNode]);

		return $changedTagNode;
	}

}
