<?php declare(strict_types = 1);

namespace PHPStan\PhpDocParser\Printer;

use LogicException;
use PHPStan\PhpDocParser\Ast\Attribute;
use PHPStan\PhpDocParser\Ast\Comment;
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode;
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNode;
use PHPStan\PhpDocParser\Ast\Node;
use PHPStan\PhpDocParser\Ast\PhpDoc\AssertTagMethodValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\AssertTagPropertyValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\AssertTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineAnnotation;
use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArgument;
use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArray;
use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArrayItem;
use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ExtendsTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ImplementsTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueParameterNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\MixinTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamClosureThisTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamImmediatelyInvokedCallableTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamLaterInvokedCallableTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamOutTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocChildNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PropertyTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PureUnlessCallableIsImpureTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\RequireExtendsTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\RequireImplementsTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\SelfOutTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ThrowsTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasImportTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\UsesTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode;
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\InvalidTypeNode;
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
use PHPStan\PhpDocParser\Lexer\Lexer;
use PHPStan\PhpDocParser\Parser\TokenIterator;
use function array_keys;
use function array_map;
use function assert;
use function count;
use function get_class;
use function get_object_vars;
use function implode;
use function in_array;
use function is_array;
use function preg_match_all;
use function sprintf;
use function str_replace;
use function strlen;
use function strpos;
use function trim;
use const PREG_SET_ORDER;

/**
 * Inspired by https://github.com/nikic/PHP-Parser/tree/36a6dcd04e7b0285e8f0868f44bd4927802f7df1
 *
 * Copyright (c) 2011, Nikita Popov
 * All rights reserved.
 */
final class Printer
{

	/** @var Differ<Node> */
	private Differ $differ;

	/**
	 * Map From "{$class}->{$subNode}" to string that should be inserted
	 * between elements of this list subnode
	 *
	 * @var array<string, string>
	 */
	private array $listInsertionMap = [
		PhpDocNode::class . '->children' => "\n * ",
		UnionTypeNode::class . '->types' => '|',
		IntersectionTypeNode::class . '->types' => '&',
		ArrayShapeNode::class . '->items' => ', ',
		ObjectShapeNode::class . '->items' => ', ',
		CallableTypeNode::class . '->parameters' => ', ',
		CallableTypeNode::class . '->templateTypes' => ', ',
		GenericTypeNode::class . '->genericTypes' => ', ',
		ConstExprArrayNode::class . '->items' => ', ',
		MethodTagValueNode::class . '->parameters' => ', ',
		DoctrineArray::class . '->items' => ', ',
		DoctrineAnnotation::class . '->arguments' => ', ',
	];

	/**
	 * [$find, $extraLeft, $extraRight]
	 *
	 * @var array<string, array{string|null, string, string}>
	 */
	private array $emptyListInsertionMap = [
		CallableTypeNode::class . '->parameters' => ['(', '', ''],
		ArrayShapeNode::class . '->items' => ['{', '', ''],
		ObjectShapeNode::class . '->items' => ['{', '', ''],
		DoctrineArray::class . '->items' => ['{', '', ''],
		DoctrineAnnotation::class . '->arguments' => ['(', '', ''],
	];

	/** @var array<string, list<class-string<TypeNode>>> */
	private array $parenthesesMap = [
		CallableTypeNode::class . '->returnType' => [
			CallableTypeNode::class,
			UnionTypeNode::class,
			IntersectionTypeNode::class,
		],
		ArrayTypeNode::class . '->type' => [
			CallableTypeNode::class,
			UnionTypeNode::class,
			IntersectionTypeNode::class,
			ConstTypeNode::class,
			NullableTypeNode::class,
		],
		OffsetAccessTypeNode::class . '->type' => [
			CallableTypeNode::class,
			UnionTypeNode::class,
			IntersectionTypeNode::class,
			NullableTypeNode::class,
		],
	];

	/** @var array<string, list<class-string<TypeNode>>> */
	private array $parenthesesListMap = [
		IntersectionTypeNode::class . '->types' => [
			IntersectionTypeNode::class,
			UnionTypeNode::class,
			NullableTypeNode::class,
		],
		UnionTypeNode::class . '->types' => [
			IntersectionTypeNode::class,
			UnionTypeNode::class,
			NullableTypeNode::class,
		],
	];

	public function printFormatPreserving(PhpDocNode $node, PhpDocNode $originalNode, TokenIterator $originalTokens): string
	{
		$this->differ = new Differ(static function ($a, $b) {
			if ($a instanceof Node && $b instanceof Node) {
				return $a === $b->getAttribute(Attribute::ORIGINAL_NODE);
			}

			return false;
		});

		$tokenIndex = 0;
		$result = $this->printArrayFormatPreserving(
			$node->children,
			$originalNode->children,
			$originalTokens,
			$tokenIndex,
			PhpDocNode::class,
			'children',
		);
		if ($result !== null) {
			return $result . $originalTokens->getContentBetween($tokenIndex, $originalTokens->getTokenCount());
		}

		return $this->print($node);
	}

	public function print(Node $node): string
	{
		if ($node instanceof PhpDocNode) {
			return "/**\n *" . implode("\n *", array_map(
				function (PhpDocChildNode $child): string {
					$s = $this->print($child);
					return $s === '' ? '' : ' ' . $s;
				},
				$node->children,
			)) . "\n */";
		}
		if ($node instanceof PhpDocTextNode) {
			return $node->text;
		}
		if ($node instanceof PhpDocTagNode) {
			if ($node->value instanceof DoctrineTagValueNode) {
				return $this->print($node->value);
			}

			return trim(sprintf('%s %s', $node->name, $this->print($node->value)));
		}
		if ($node instanceof PhpDocTagValueNode) {
			return $this->printTagValue($node);
		}
		if ($node instanceof TypeNode) {
			return $this->printType($node);
		}
		if ($node instanceof ConstExprNode) {
			return $this->printConstExpr($node);
		}
		if ($node instanceof MethodTagValueParameterNode) {
			$type = $node->type !== null ? $this->print($node->type) . ' ' : '';
			$isReference = $node->isReference ? '&' : '';
			$isVariadic = $node->isVariadic ? '...' : '';
			$default = $node->defaultValue !== null ? ' = ' . $this->print($node->defaultValue) : '';
			return "{$type}{$isReference}{$isVariadic}{$node->parameterName}{$default}";
		}
		if ($node instanceof CallableTypeParameterNode) {
			$type = $this->print($node->type) . ' ';
			$isReference = $node->isReference ? '&' : '';
			$isVariadic = $node->isVariadic ? '...' : '';
			$isOptional = $node->isOptional ? '=' : '';
			return trim("{$type}{$isReference}{$isVariadic}{$node->parameterName}") . $isOptional;
		}
		if ($node instanceof ArrayShapeUnsealedTypeNode) {
			if ($node->keyType !== null) {
				return sprintf('<%s, %s>', $this->printType($node->keyType), $this->printType($node->valueType));
			}
			return sprintf('<%s>', $this->printType($node->valueType));
		}
		if ($node instanceof DoctrineAnnotation) {
			return (string) $node;
		}
		if ($node instanceof DoctrineArgument) {
			return (string) $node;
		}
		if ($node instanceof DoctrineArray) {
			return (string) $node;
		}
		if ($node instanceof DoctrineArrayItem) {
			return (string) $node;
		}
		if ($node instanceof ArrayShapeItemNode) {
			if ($node->keyName !== null) {
				return sprintf(
					'%s%s: %s',
					$this->print($node->keyName),
					$node->optional ? '?' : '',
					$this->printType($node->valueType),
				);
			}

			return $this->printType($node->valueType);
		}
		if ($node instanceof ObjectShapeItemNode) {
			if ($node->keyName !== null) {
				return sprintf(
					'%s%s: %s',
					$this->print($node->keyName),
					$node->optional ? '?' : '',
					$this->printType($node->valueType),
				);
			}

			return $this->printType($node->valueType);
		}

		throw new LogicException(sprintf('Unknown node type %s', get_class($node)));
	}

	private function printTagValue(PhpDocTagValueNode $node): string
	{
		// only nodes that contain another node are handled here
		// the rest falls back on (string) $node

		if ($node instanceof AssertTagMethodValueNode) {
			$isNegated = $node->isNegated ? '!' : '';
			$isEquality = $node->isEquality ? '=' : '';
			$type = $this->printType($node->type);
			return trim("{$isNegated}{$isEquality}{$type} {$node->parameter}->{$node->method}() {$node->description}");
		}
		if ($node instanceof AssertTagPropertyValueNode) {
			$isNegated = $node->isNegated ? '!' : '';
			$isEquality = $node->isEquality ? '=' : '';
			$type = $this->printType($node->type);
			return trim("{$isNegated}{$isEquality}{$type} {$node->parameter}->{$node->property} {$node->description}");
		}
		if ($node instanceof AssertTagValueNode) {
			$isNegated = $node->isNegated ? '!' : '';
			$isEquality = $node->isEquality ? '=' : '';
			$type = $this->printType($node->type);
			return trim("{$isNegated}{$isEquality}{$type} {$node->parameter} {$node->description}");
		}
		if ($node instanceof ExtendsTagValueNode || $node instanceof ImplementsTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} {$node->description}");
		}
		if ($node instanceof MethodTagValueNode) {
			$static = $node->isStatic ? 'static ' : '';
			$returnType = $node->returnType !== null ? $this->printType($node->returnType) . ' ' : '';
			$parameters = implode(', ', array_map(fn (MethodTagValueParameterNode $parameter): string => $this->print($parameter), $node->parameters));
			$description = $node->description !== '' ? " {$node->description}" : '';
			$templateTypes = count($node->templateTypes) > 0 ? '<' . implode(', ', array_map(fn (TemplateTagValueNode $templateTag): string => $this->print($templateTag), $node->templateTypes)) . '>' : '';
			return "{$static}{$returnType}{$node->methodName}{$templateTypes}({$parameters}){$description}";
		}
		if ($node instanceof MixinTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} {$node->description}");
		}
		if ($node instanceof RequireExtendsTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} {$node->description}");
		}
		if ($node instanceof RequireImplementsTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} {$node->description}");
		}
		if ($node instanceof ParamOutTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} {$node->parameterName} {$node->description}");
		}
		if ($node instanceof ParamTagValueNode) {
			$reference = $node->isReference ? '&' : '';
			$variadic = $node->isVariadic ? '...' : '';
			$type = $this->printType($node->type);
			return trim("{$type} {$reference}{$variadic}{$node->parameterName} {$node->description}");
		}
		if ($node instanceof ParamImmediatelyInvokedCallableTagValueNode) {
			return trim("{$node->parameterName} {$node->description}");
		}
		if ($node instanceof ParamLaterInvokedCallableTagValueNode) {
			return trim("{$node->parameterName} {$node->description}");
		}
		if ($node instanceof ParamClosureThisTagValueNode) {
			return trim("{$node->type} {$node->parameterName} {$node->description}");
		}
		if ($node instanceof PureUnlessCallableIsImpureTagValueNode) {
			return trim("{$node->parameterName} {$node->description}");
		}
		if ($node instanceof PropertyTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} {$node->propertyName} {$node->description}");
		}
		if ($node instanceof ReturnTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} {$node->description}");
		}
		if ($node instanceof SelfOutTagValueNode) {
			$type = $this->printType($node->type);
			return trim($type . ' ' . $node->description);
		}
		if ($node instanceof TemplateTagValueNode) {
			$upperBound = $node->bound !== null ? ' of ' . $this->printType($node->bound) : '';
			$lowerBound = $node->lowerBound !== null ? ' super ' . $this->printType($node->lowerBound) : '';
			$default = $node->default !== null ? ' = ' . $this->printType($node->default) : '';
			return trim("{$node->name}{$upperBound}{$lowerBound}{$default} {$node->description}");
		}
		if ($node instanceof ThrowsTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} {$node->description}");
		}
		if ($node instanceof TypeAliasImportTagValueNode) {
			return trim(
				"{$node->importedAlias} from " . $this->printType($node->importedFrom)
				. ($node->importedAs !== null ? " as {$node->importedAs}" : ''),
			);
		}
		if ($node instanceof TypeAliasTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$node->alias} {$type}");
		}
		if ($node instanceof UsesTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} {$node->description}");
		}
		if ($node instanceof VarTagValueNode) {
			$type = $this->printType($node->type);
			return trim("{$type} " . trim("{$node->variableName} {$node->description}"));
		}

		return (string) $node;
	}

	private function printType(TypeNode $node): string
	{
		if ($node instanceof ArrayShapeNode) {
			$items = array_map(fn (ArrayShapeItemNode $item): string => $this->print($item), $node->items);

			if (! $node->sealed) {
				$items[] = '...' . ($node->unsealedType === null ? '' : $this->print($node->unsealedType));
			}

			return $node->kind . '{' . implode(', ', $items) . '}';
		}
		if ($node instanceof ArrayTypeNode) {
			return $this->printOffsetAccessType($node->type) . '[]';
		}
		if ($node instanceof CallableTypeNode) {
			if ($node->returnType instanceof CallableTypeNode || $node->returnType instanceof UnionTypeNode || $node->returnType instanceof IntersectionTypeNode) {
				$returnType = $this->wrapInParentheses($node->returnType);
			} else {
				$returnType = $this->printType($node->returnType);
			}
			$template = $node->templateTypes !== []
				? '<' . implode(', ', array_map(fn (TemplateTagValueNode $templateNode): string => $this->print($templateNode), $node->templateTypes)) . '>'
				: '';
			$parameters = implode(', ', array_map(fn (CallableTypeParameterNode $parameterNode): string => $this->print($parameterNode), $node->parameters));
			return "{$node->identifier}{$template}({$parameters}): {$returnType}";
		}
		if ($node instanceof ConditionalTypeForParameterNode) {
			return sprintf(
				'(%s %s %s ? %s : %s)',
				$node->parameterName,
				$node->negated ? 'is not' : 'is',
				$this->printType($node->targetType),
				$this->printType($node->if),
				$this->printType($node->else),
			);
		}
		if ($node instanceof ConditionalTypeNode) {
			return sprintf(
				'(%s %s %s ? %s : %s)',
				$this->printType($node->subjectType),
				$node->negated ? 'is not' : 'is',
				$this->printType($node->targetType),
				$this->printType($node->if),
				$this->printType($node->else),
			);
		}
		if ($node instanceof ConstTypeNode) {
			return $this->printConstExpr($node->constExpr);
		}
		if ($node instanceof GenericTypeNode) {
			$genericTypes = [];

			foreach ($node->genericTypes as $index => $type) {
				$variance = $node->variances[$index] ?? GenericTypeNode::VARIANCE_INVARIANT;
				if ($variance === GenericTypeNode::VARIANCE_INVARIANT) {
					$genericTypes[] = $this->printType($type);
				} elseif ($variance === GenericTypeNode::VARIANCE_BIVARIANT) {
					$genericTypes[] = '*';
				} else {
					$genericTypes[] = sprintf('%s %s', $variance, $this->print($type));
				}
			}

			return $node->type . '<' . implode(', ', $genericTypes) . '>';
		}
		if ($node instanceof IdentifierTypeNode) {
			return $node->name;
		}
		if ($node instanceof IntersectionTypeNode || $node instanceof UnionTypeNode) {
			$items = [];
			foreach ($node->types as $type) {
				if (
					$type instanceof IntersectionTypeNode
					|| $type instanceof UnionTypeNode
					|| $type instanceof NullableTypeNode
				) {
					$items[] = $this->wrapInParentheses($type);
					continue;
				}

				$items[] = $this->printType($type);
			}

			return implode($node instanceof IntersectionTypeNode ? '&' : '|', $items);
		}
		if ($node instanceof InvalidTypeNode) {
			return (string) $node;
		}
		if ($node instanceof NullableTypeNode) {
			if ($node->type instanceof IntersectionTypeNode || $node->type instanceof UnionTypeNode) {
				return '?(' . $this->printType($node->type) . ')';
			}

			return '?' . $this->printType($node->type);
		}
		if ($node instanceof ObjectShapeNode) {
			$items = array_map(fn (ObjectShapeItemNode $item): string => $this->print($item), $node->items);

			return 'object{' . implode(', ', $items) . '}';
		}
		if ($node instanceof OffsetAccessTypeNode) {
			return $this->printOffsetAccessType($node->type) . '[' . $this->printType($node->offset) . ']';
		}
		if ($node instanceof ThisTypeNode) {
			return (string) $node;
		}

		throw new LogicException(sprintf('Unknown node type %s', get_class($node)));
	}

	private function wrapInParentheses(TypeNode $node): string
	{
		return '(' . $this->printType($node) . ')';
	}

	private function printOffsetAccessType(TypeNode $type): string
	{
		if (
			$type instanceof CallableTypeNode
			|| $type instanceof UnionTypeNode
			|| $type instanceof IntersectionTypeNode
			|| $type instanceof NullableTypeNode
		) {
			return $this->wrapInParentheses($type);
		}

		return $this->printType($type);
	}

	private function printConstExpr(ConstExprNode $node): string
	{
		// this is fine - ConstExprNode classes do not contain nodes that need smart printer logic
		return (string) $node;
	}

	/**
	 * @param Node[] $nodes
	 * @param Node[] $originalNodes
	 */
	private function printArrayFormatPreserving(array $nodes, array $originalNodes, TokenIterator $originalTokens, int &$tokenIndex, string $parentNodeClass, string $subNodeName): ?string
	{
		$diff = $this->differ->diffWithReplacements($originalNodes, $nodes);
		$mapKey = $parentNodeClass . '->' . $subNodeName;
		$insertStr = $this->listInsertionMap[$mapKey] ?? null;
		$result = '';
		$beforeFirstKeepOrReplace = true;
		$delayedAdd = [];

		$insertNewline = false;
		[$isMultiline, $beforeAsteriskIndent, $afterAsteriskIndent] = $this->isMultiline($tokenIndex, $originalNodes, $originalTokens);

		if ($insertStr === "\n * ") {
			$insertStr = sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
		}

		foreach ($diff as $i => $diffElem) {
			$diffType = $diffElem->type;
			$arrItem = $diffElem->new;
			$origArrayItem = $diffElem->old;
			if ($diffType === DiffElem::TYPE_KEEP || $diffType === DiffElem::TYPE_REPLACE) {
				$beforeFirstKeepOrReplace = false;
				if (!$arrItem instanceof Node || !$origArrayItem instanceof Node) {
					return null;
				}

				/** @var int $itemStartPos */
				$itemStartPos = $origArrayItem->getAttribute(Attribute::START_INDEX);

				/** @var int $itemEndPos */
				$itemEndPos = $origArrayItem->getAttribute(Attribute::END_INDEX);

				if ($itemStartPos < 0 || $itemEndPos < 0 || $itemStartPos < $tokenIndex) {
					throw new LogicException();
				}

				$comments = $arrItem->getAttribute(Attribute::COMMENTS) ?? [];
				$origComments = $origArrayItem->getAttribute(Attribute::COMMENTS) ?? [];

				$commentStartPos = count($origComments) > 0 ? $origComments[0]->startIndex : $itemStartPos;
				assert($commentStartPos >= 0);

				$result .= $originalTokens->getContentBetween($tokenIndex, $itemStartPos);

				if (count($delayedAdd) > 0) {
					foreach ($delayedAdd as $delayedAddNode) {
						$parenthesesNeeded = isset($this->parenthesesListMap[$mapKey])
							&& in_array(get_class($delayedAddNode), $this->parenthesesListMap[$mapKey], true);
						if ($parenthesesNeeded) {
							$result .= '(';
						}

						if ($insertNewline) {
							$delayedAddComments = $delayedAddNode->getAttribute(Attribute::COMMENTS) ?? [];
							if (count($delayedAddComments) > 0) {
								$result .= $this->printComments($delayedAddComments, $beforeAsteriskIndent, $afterAsteriskIndent);
								$result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
							}
						}

						$result .= $this->printNodeFormatPreserving($delayedAddNode, $originalTokens);
						if ($parenthesesNeeded) {
							$result .= ')';
						}

						if ($insertNewline) {
							$result .= $insertStr . sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
						} else {
							$result .= $insertStr;
						}
					}

					$delayedAdd = [];
				}

				$parenthesesNeeded = isset($this->parenthesesListMap[$mapKey])
					&& in_array(get_class($arrItem), $this->parenthesesListMap[$mapKey], true)
					&& !in_array(get_class($origArrayItem), $this->parenthesesListMap[$mapKey], true);
				$addParentheses = $parenthesesNeeded && !$originalTokens->hasParentheses($itemStartPos, $itemEndPos);
				if ($addParentheses) {
					$result .= '(';
				}

				if ($comments !== $origComments) {
					if (count($comments) > 0) {
						$result .= $this->printComments($comments, $beforeAsteriskIndent, $afterAsteriskIndent);
						$result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
					}
				}

				$result .= $this->printNodeFormatPreserving($arrItem, $originalTokens);
				if ($addParentheses) {
					$result .= ')';
				}
				$tokenIndex = $itemEndPos + 1;

			} elseif ($diffType === DiffElem::TYPE_ADD) {
				if ($insertStr === null) {
					return null;
				}
				if (!$arrItem instanceof Node) {
					return null;
				}

				if ($insertStr === ', ' && $isMultiline || count($arrItem->getAttribute(Attribute::COMMENTS) ?? []) > 0) {
					$insertStr = ',';
					$insertNewline = true;
				}

				if ($beforeFirstKeepOrReplace) {
					// Will be inserted at the next "replace" or "keep" element
					$delayedAdd[] = $arrItem;
					continue;
				}

				/** @var int $itemEndPos */
				$itemEndPos = $tokenIndex - 1;
				if ($insertNewline) {
					$comments = $arrItem->getAttribute(Attribute::COMMENTS) ?? [];
					$result .= $insertStr;
					if (count($comments) > 0) {
						$result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
						$result .= $this->printComments($comments, $beforeAsteriskIndent, $afterAsteriskIndent);
					}
					$result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
				} else {
					$result .= $insertStr;
				}

				$parenthesesNeeded = isset($this->parenthesesListMap[$mapKey])
					&& in_array(get_class($arrItem), $this->parenthesesListMap[$mapKey], true);
				if ($parenthesesNeeded) {
					$result .= '(';
				}

				$result .= $this->printNodeFormatPreserving($arrItem, $originalTokens);
				if ($parenthesesNeeded) {
					$result .= ')';
				}

				$tokenIndex = $itemEndPos + 1;

			} elseif ($diffType === DiffElem::TYPE_REMOVE) {
				if (!$origArrayItem instanceof Node) {
					return null;
				}

				/** @var int $itemStartPos */
				$itemStartPos = $origArrayItem->getAttribute(Attribute::START_INDEX);

				/** @var int $itemEndPos */
				$itemEndPos = $origArrayItem->getAttribute(Attribute::END_INDEX);
				if ($itemStartPos < 0 || $itemEndPos < 0) {
					throw new LogicException();
				}

				if ($i === 0) {
					// If we're removing from the start, keep the tokens before the node and drop those after it,
					// instead of the other way around.
					$originalTokensArray = $originalTokens->getTokens();
					for ($j = $tokenIndex; $j < $itemStartPos; $j++) {
						if ($originalTokensArray[$j][Lexer::TYPE_OFFSET] === Lexer::TOKEN_PHPDOC_EOL) {
							break;
						}
						$result .= $originalTokensArray[$j][Lexer::VALUE_OFFSET];
					}
				}

				$tokenIndex = $itemEndPos + 1;
			}
		}

		if (count($delayedAdd) > 0) {
			if (!isset($this->emptyListInsertionMap[$mapKey])) {
				return null;
			}

			[$findToken, $extraLeft, $extraRight] = $this->emptyListInsertionMap[$mapKey];
			if ($findToken !== null) {
				$originalTokensArray = $originalTokens->getTokens();
				for (; $tokenIndex < count($originalTokensArray); $tokenIndex++) {
					$result .= $originalTokensArray[$tokenIndex][Lexer::VALUE_OFFSET];
					if ($originalTokensArray[$tokenIndex][Lexer::VALUE_OFFSET] !== $findToken) {
						continue;
					}

					$tokenIndex++;
					break;
				}
			}
			$first = true;
			$result .= $extraLeft;
			foreach ($delayedAdd as $delayedAddNode) {
				if (!$first) {
					$result .= $insertStr;
					if ($insertNewline) {
						$result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
					}
				}

				$result .= $this->printNodeFormatPreserving($delayedAddNode, $originalTokens);
				$first = false;
			}
			$result .= $extraRight;
		}

		return $result;
	}

	/**
	 * @param list<Comment> $comments
	 */
	private function printComments(array $comments, string $beforeAsteriskIndent, string $afterAsteriskIndent): string
	{
		$formattedComments = [];

		foreach ($comments as $comment) {
			$formattedComments[] = str_replace("\n", "\n" . $beforeAsteriskIndent . '*' . $afterAsteriskIndent, $comment->getReformattedText());
		}

		return implode("\n$beforeAsteriskIndent*$afterAsteriskIndent", $formattedComments);
	}

	/**
	 * @param array<Node|null> $nodes
	 * @return array{bool, string, string}
	 */
	private function isMultiline(int $initialIndex, array $nodes, TokenIterator $originalTokens): array
	{
		$isMultiline = count($nodes) > 1;
		$pos = $initialIndex;
		$allText = '';
		/** @var Node|null $node */
		foreach ($nodes as $node) {
			if (!$node instanceof Node) {
				continue;
			}

			$endPos = $node->getAttribute(Attribute::END_INDEX) + 1;
			$text = $originalTokens->getContentBetween($pos, $endPos);
			$allText .= $text;
			if (strpos($text, "\n") === false) {
				// We require that a newline is present between *every* item. If the formatting
				// is inconsistent, with only some items having newlines, we don't consider it
				// as multiline
				$isMultiline = false;
			}
			$pos = $endPos;
		}

		$c = preg_match_all('~\n(?<before>[\\x09\\x20]*)\*(?<after>\\x20*)~', $allText, $matches, PREG_SET_ORDER);
		if ($c === 0) {
			return [$isMultiline, ' ', '  '];
		}

		$before = '';
		$after = '';
		foreach ($matches as $match) {
			if (strlen($match['before']) > strlen($before)) {
				$before = $match['before'];
			}
			if (strlen($match['after']) <= strlen($after)) {
				continue;
			}

			$after = $match['after'];
		}

		$before = strlen($before) === 0 ? ' ' : $before;
		$after = strlen($after) === 0 ? '  ' : $after;

		return [$isMultiline, $before, $after];
	}

	private function printNodeFormatPreserving(Node $node, TokenIterator $originalTokens): string
	{
		/** @var Node|null $originalNode */
		$originalNode = $node->getAttribute(Attribute::ORIGINAL_NODE);
		if ($originalNode === null) {
			return $this->print($node);
		}

		$class = get_class($node);
		if ($class !== get_class($originalNode)) {
			throw new LogicException();
		}

		$startPos = $originalNode->getAttribute(Attribute::START_INDEX);
		$endPos = $originalNode->getAttribute(Attribute::END_INDEX);
		if ($startPos < 0 || $endPos < 0) {
			throw new LogicException();
		}

		$result = '';
		$pos = $startPos;
		$subNodeNames = array_keys(get_object_vars($node));
		foreach ($subNodeNames as $subNodeName) {
			$subNode = $node->$subNodeName;
			$origSubNode = $originalNode->$subNodeName;

			if (
				(!$subNode instanceof Node && $subNode !== null)
				|| (!$origSubNode instanceof Node && $origSubNode !== null)
			) {
				if ($subNode === $origSubNode) {
					// Unchanged, can reuse old code
					continue;
				}

				if (is_array($subNode) && is_array($origSubNode)) {
					// Array subnode changed, we might be able to reconstruct it
					$listResult = $this->printArrayFormatPreserving(
						$subNode,
						$origSubNode,
						$originalTokens,
						$pos,
						$class,
						$subNodeName,
					);

					if ($listResult === null) {
						return $this->print($node);
					}

					$result .= $listResult;
					continue;
				}

				return $this->print($node);
			}

			if ($origSubNode === null) {
				if ($subNode === null) {
					// Both null, nothing to do
					continue;
				}

				return $this->print($node);
			}

			$subStartPos = $origSubNode->getAttribute(Attribute::START_INDEX);
			$subEndPos = $origSubNode->getAttribute(Attribute::END_INDEX);
			if ($subStartPos < 0 || $subEndPos < 0) {
				throw new LogicException();
			}

			if ($subEndPos < $subStartPos) {
				return $this->print($node);
			}

			if ($subNode === null) {
				return $this->print($node);
			}

			$result .= $originalTokens->getContentBetween($pos, $subStartPos);
			$mapKey = get_class($node) . '->' . $subNodeName;
			$parenthesesNeeded = isset($this->parenthesesMap[$mapKey])
				&& in_array(get_class($subNode), $this->parenthesesMap[$mapKey], true);

			if ($subNode->getAttribute(Attribute::ORIGINAL_NODE) !== null) {
				$parenthesesNeeded = $parenthesesNeeded
					&& !in_array(get_class($subNode->getAttribute(Attribute::ORIGINAL_NODE)), $this->parenthesesMap[$mapKey], true);
			}

			$addParentheses = $parenthesesNeeded && !$originalTokens->hasParentheses($subStartPos, $subEndPos);
			if ($addParentheses) {
				$result .= '(';
			}

			$result .= $this->printNodeFormatPreserving($subNode, $originalTokens);
			if ($addParentheses) {
				$result .= ')';
			}

			$pos = $subEndPos + 1;
		}

		return $result . $originalTokens->getContentBetween($pos, $endPos + 1);
	}

}
