<?php

declare (strict_types=1);
namespace Rector\Php80\Rector\Class_;

use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Property;
use PhpParser\Node\UnionType;
use PhpParser\NodeVisitor;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Type\MixedType;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeWithClassName;
use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfo;
use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfoFactory;
use Rector\Contract\Rector\ConfigurableRectorInterface;
use Rector\Naming\PropertyRenamer\PropertyPromotionRenamer;
use Rector\Naming\VariableRenamer;
use Rector\NodeAnalyzer\ParamAnalyzer;
use Rector\NodeTypeResolver\TypeComparator\TypeComparator;
use Rector\Php80\DocBlock\PropertyPromotionDocBlockMerger;
use Rector\Php80\Guard\MakePropertyPromotionGuard;
use Rector\Php80\NodeAnalyzer\PromotedPropertyCandidateResolver;
use Rector\PhpParser\Node\BetterNodeFinder;
use Rector\PhpParser\Node\Value\ValueResolver;
use Rector\PHPStanStaticTypeMapper\Enum\TypeKind;
use Rector\Rector\AbstractRector;
use Rector\Reflection\ReflectionResolver;
use Rector\StaticTypeMapper\StaticTypeMapper;
use Rector\ValueObject\MethodName;
use Rector\ValueObject\PhpVersionFeature;
use Rector\VersionBonding\Contract\MinPhpVersionInterface;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\ConfiguredCodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
/**
 * @see \Rector\Tests\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector\ClassPropertyAssignToConstructorPromotionRectorTest
 */
final class ClassPropertyAssignToConstructorPromotionRector extends AbstractRector implements MinPhpVersionInterface, ConfigurableRectorInterface
{
    /**
     * @readonly
     */
    private PromotedPropertyCandidateResolver $promotedPropertyCandidateResolver;
    /**
     * @readonly
     */
    private VariableRenamer $variableRenamer;
    /**
     * @readonly
     */
    private ParamAnalyzer $paramAnalyzer;
    /**
     * @readonly
     */
    private PropertyPromotionDocBlockMerger $propertyPromotionDocBlockMerger;
    /**
     * @readonly
     */
    private MakePropertyPromotionGuard $makePropertyPromotionGuard;
    /**
     * @readonly
     */
    private TypeComparator $typeComparator;
    /**
     * @readonly
     */
    private ReflectionResolver $reflectionResolver;
    /**
     * @readonly
     */
    private PropertyPromotionRenamer $propertyPromotionRenamer;
    /**
     * @readonly
     */
    private PhpDocInfoFactory $phpDocInfoFactory;
    /**
     * @readonly
     */
    private StaticTypeMapper $staticTypeMapper;
    /**
     * @readonly
     */
    private ValueResolver $valueResolver;
    /**
     * @readonly
     */
    private BetterNodeFinder $betterNodeFinder;
    /**
     * @api
     * @var string
     */
    public const INLINE_PUBLIC = 'inline_public';
    /**
     * @api
     * @var string
     */
    public const RENAME_PROPERTY = 'rename_property';
    /**
     * @api
     * @var string
     */
    public const ALLOW_MODEL_BASED_CLASSES = 'allow_model_based_classes';
    /**
     * Default to false, which only apply changes:
     *
     *  – private modifier property
     *  - protected/public modifier property when property typed
     *
     * Set to true will allow change whether property is typed or not as far as not forbidden, eg: callable type, null type, etc.
     */
    private bool $inlinePublic = \false;
    /**
     * Set to false will skip property promotion when parameter and property have different names.
     */
    private bool $renameProperty = \true;
    /**
     * Set to false will skip property promotion on model based classes
     */
    private bool $allowModelBasedClasses = \true;
    public function __construct(PromotedPropertyCandidateResolver $promotedPropertyCandidateResolver, VariableRenamer $variableRenamer, ParamAnalyzer $paramAnalyzer, PropertyPromotionDocBlockMerger $propertyPromotionDocBlockMerger, MakePropertyPromotionGuard $makePropertyPromotionGuard, TypeComparator $typeComparator, ReflectionResolver $reflectionResolver, PropertyPromotionRenamer $propertyPromotionRenamer, PhpDocInfoFactory $phpDocInfoFactory, StaticTypeMapper $staticTypeMapper, ValueResolver $valueResolver, BetterNodeFinder $betterNodeFinder)
    {
        $this->promotedPropertyCandidateResolver = $promotedPropertyCandidateResolver;
        $this->variableRenamer = $variableRenamer;
        $this->paramAnalyzer = $paramAnalyzer;
        $this->propertyPromotionDocBlockMerger = $propertyPromotionDocBlockMerger;
        $this->makePropertyPromotionGuard = $makePropertyPromotionGuard;
        $this->typeComparator = $typeComparator;
        $this->reflectionResolver = $reflectionResolver;
        $this->propertyPromotionRenamer = $propertyPromotionRenamer;
        $this->phpDocInfoFactory = $phpDocInfoFactory;
        $this->staticTypeMapper = $staticTypeMapper;
        $this->valueResolver = $valueResolver;
        $this->betterNodeFinder = $betterNodeFinder;
    }
    public function getRuleDefinition(): RuleDefinition
    {
        return new RuleDefinition('Change simple property init and assign to constructor promotion', [new ConfiguredCodeSample(<<<'CODE_SAMPLE'
class SomeClass
{
    public float $price;

    public function __construct(
        float $price = 0.0
    ) {
        $this->price = $price;
    }
}
CODE_SAMPLE
, <<<'CODE_SAMPLE'
class SomeClass
{
    public function __construct(
        public float $price = 0.0
    ) {
    }
}
CODE_SAMPLE
, [self::INLINE_PUBLIC => \false, self::RENAME_PROPERTY => \true, self::ALLOW_MODEL_BASED_CLASSES => \true])]);
    }
    /**
     * @param array<string, mixed> $configuration
     */
    public function configure(array $configuration): void
    {
        $this->inlinePublic = $configuration[self::INLINE_PUBLIC] ?? \false;
        $this->renameProperty = $configuration[self::RENAME_PROPERTY] ?? \true;
        $this->allowModelBasedClasses = $configuration[self::ALLOW_MODEL_BASED_CLASSES] ?? \true;
    }
    /**
     * @return array<class-string<Node>>
     */
    public function getNodeTypes(): array
    {
        return [Class_::class];
    }
    /**
     * @param Class_ $node
     */
    public function refactor(Node $node): ?Node
    {
        $constructClassMethod = $node->getMethod(MethodName::CONSTRUCT);
        if (!$constructClassMethod instanceof ClassMethod) {
            return null;
        }
        $promotionCandidates = $this->promotedPropertyCandidateResolver->resolveFromClass($node, $constructClassMethod, $this->allowModelBasedClasses);
        if ($promotionCandidates === []) {
            return null;
        }
        $classReflection = $this->reflectionResolver->resolveClassReflection($node);
        if (!$classReflection instanceof ClassReflection) {
            return null;
        }
        $constructorPhpDocInfo = $this->phpDocInfoFactory->createFromNodeOrEmpty($constructClassMethod);
        $hasChanged = \false;
        foreach ($promotionCandidates as $promotionCandidate) {
            $param = $promotionCandidate->getParam();
            if ($this->shouldSkipParam($param)) {
                continue;
            }
            $property = $promotionCandidate->getProperty();
            if (!$this->makePropertyPromotionGuard->isLegal($node, $classReflection, $property, $param, $this->inlinePublic)) {
                continue;
            }
            $paramName = $this->getName($param);
            // rename also following calls
            $propertyName = $this->getName($property->props[0]);
            if (!$this->renameProperty && $paramName !== $propertyName) {
                continue;
            }
            if ($this->shouldSkipPropertyOrParam($property, $param)) {
                continue;
            }
            if ($this->shouldSkipNarrowingVarDoc($property, $param, $constructorPhpDocInfo, $paramName)) {
                continue;
            }
            if ($this->shouldSkipPropertyAssignedNull($node, $propertyName)) {
                continue;
            }
            $hasChanged = \true;
            // remove property from class
            unset($node->stmts[$promotionCandidate->getPropertyStmtPosition()]);
            // remove assign in constructor
            unset($constructClassMethod->stmts[$promotionCandidate->getConstructorAssignStmtPosition()]);
            $oldParamName = $this->getName($param);
            $this->variableRenamer->renameVariableInFunctionLike($constructClassMethod, $oldParamName, $propertyName);
            $paramTagValueNode = $constructorPhpDocInfo->getParamTagValueByName($paramName);
            if (!$paramTagValueNode instanceof ParamTagValueNode) {
                $this->propertyPromotionDocBlockMerger->decorateParamWithPropertyPhpDocInfo($constructClassMethod, $property, $param, $paramName);
            } elseif ($paramTagValueNode->parameterName !== '$' . $propertyName) {
                $this->propertyPromotionRenamer->renameParamDoc($constructorPhpDocInfo, $constructClassMethod, $param, $paramTagValueNode->parameterName, $propertyName);
            }
            // property name has higher priority
            $paramName = $this->getName($property);
            $param->var = new Variable($paramName);
            $param->flags = $property->flags;
            $param->hooks = $property->hooks;
            // copy attributes of the old property
            $param->attrGroups = array_merge($param->attrGroups, $property->attrGroups);
            $this->processUnionType($property, $param);
            $this->propertyPromotionDocBlockMerger->mergePropertyAndParamDocBlocks($property, $param, $paramTagValueNode);
            // update variable to property fetch references
            $this->traverseNodesWithCallable((array) $constructClassMethod->stmts, function (Node $node) use ($promotionCandidate, $propertyName) {
                if ($node instanceof Class_ || $node instanceof FunctionLike) {
                    return NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN;
                }
                if (!$node instanceof Variable) {
                    return null;
                }
                if (!$this->isName($node, $promotionCandidate->getParamName())) {
                    return null;
                }
                return new PropertyFetch(new Variable('this'), $propertyName);
            });
        }
        if (!$hasChanged) {
            return null;
        }
        return $node;
    }
    public function provideMinPhpVersion(): int
    {
        return PhpVersionFeature::PROPERTY_PROMOTION;
    }
    private function processUnionType(Property $property, Param $param): void
    {
        if ($this->shouldUsePropertyTypeForPromotedParam($property, $param)) {
            $param->type = $property->type;
            return;
        }
        if (!$param->type instanceof Node) {
            return;
        }
        $paramType = $this->getType($param->type);
        if ($this->shouldRemoveNullFromForPromotedParamType($property, $param)) {
            $paramType = TypeCombinator::removeNull($paramType);
            $param->type = $this->staticTypeMapper->mapPHPStanTypeToPhpParserNode($paramType, TypeKind::PARAM);
        }
        if (!$param->default instanceof Expr) {
            return;
        }
        $defaultType = $this->getType($param->default);
        if ($this->typeComparator->isSubtype($defaultType, $paramType)) {
            return;
        }
        if ($this->typeComparator->areTypesEqual($defaultType, $paramType)) {
            return;
        }
        if ($paramType instanceof MixedType) {
            return;
        }
        $paramType = TypeCombinator::union($paramType, $defaultType);
        $param->type = $this->staticTypeMapper->mapPHPStanTypeToPhpParserNode($paramType, TypeKind::PARAM);
    }
    private function shouldSkipParam(Param $param): bool
    {
        if ($param->variadic) {
            return \true;
        }
        if ($this->paramAnalyzer->isNullable($param)) {
            /** @var NullableType $type */
            $type = $param->type;
            $type = $type->type;
        } else {
            $type = $param->type;
        }
        if ($this->isCallableTypeIdentifier($type)) {
            return \true;
        }
        if (!$type instanceof UnionType) {
            return \false;
        }
        $found = \false;
        foreach ($type->types as $type) {
            if ($this->isCallableTypeIdentifier($type)) {
                $found = \true;
                break;
            }
        }
        return $found;
    }
    private function isCallableTypeIdentifier(?Node $node): bool
    {
        if (!$node instanceof Identifier) {
            return \false;
        }
        return $this->isName($node, 'callable');
    }
    private function shouldSkipPropertyOrParam(Property $property, Param $param): bool
    {
        foreach ($property->hooks as $hook) {
            if (!is_array($hook->body)) {
                continue;
            }
            if (count($hook->body) > 1) {
                return \true;
            }
        }
        return $property->type instanceof Node && $param->type instanceof Node && $property->hooks !== [] && !$this->nodeComparator->areNodesEqual($property->type, $param->type);
    }
    /**
     * A class-typed property may carry a narrowing @var (e.g. native AdapterInterface, @var CacheProvider) used to
     * type calls on the property. Promotion would drop that @var (it is not preserved as a @param here), $property so skip to
     * avoid losing type information.
     */
    private function shouldSkipNarrowingVarDoc(Property $property, Param $param, PhpDocInfo $constructorPhpDocInfo, string $paramName): bool
    {
        if (!$property->type instanceof Node || !$param->type instanceof Node) {
            return \false;
        }
        // an explicit @param already carries the type onto the promoted property
        if ($constructorPhpDocInfo->getParamTagValueByName($paramName) instanceof ParamTagValueNode) {
            return \false;
        }
        $propertyPhpDocInfo = $this->phpDocInfoFactory->createFromNodeOrEmpty($property);
        $varTagValueNode = $propertyPhpDocInfo->getVarTagValueNode();
        if (!$varTagValueNode instanceof VarTagValueNode) {
            return \false;
        }
        // a description keeps the docblock around anyway
        if ($varTagValueNode->description !== '') {
            return \false;
        }
        $varType = $this->staticTypeMapper->mapPHPStanPhpDocTypeToPHPStanType($varTagValueNode, $property);
        // only class-typed @var narrowing is lost; generics, arrays and int ranges are preserved on promotion
        if (!$varType instanceof TypeWithClassName) {
            return \false;
        }
        $paramType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($param->type);
        return !$this->typeComparator->areTypesEqual($varType, $paramType);
    }
    private function shouldRemoveNullFromForPromotedParamType(Property $property, Param $param): bool
    {
        if (!$property->type instanceof Node || !$param->type instanceof Node) {
            return \false;
        }
        if ($param->default instanceof Expr && $this->valueResolver->isNull($param->default)) {
            return \false;
        }
        $propertyType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($property->type);
        $type = TypeCombinator::removeNull($propertyType);
        if (!$this->typeComparator->areTypesEqual($type, $propertyType)) {
            return \false;
        }
        $paramType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($param->type);
        $paramTypeWithoutNull = TypeCombinator::removeNull($paramType);
        return !$this->typeComparator->areTypesEqual($paramTypeWithoutNull, $paramType);
    }
    private function shouldUsePropertyTypeForPromotedParam(Property $property, Param $param): bool
    {
        if (!$property->type instanceof Node) {
            return \false;
        }
        if (!$param->type instanceof Node) {
            return \true;
        }
        $propertyType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($property->type);
        $type = TypeCombinator::removeNull($propertyType);
        $paramType = $this->staticTypeMapper->mapPhpParserNodePHPStanType($param->type);
        $paramTypeWithoutNull = TypeCombinator::removeNull($paramType);
        if (!$this->typeComparator->areTypesEqual($type, $paramTypeWithoutNull)) {
            return \false;
        }
        if ($param->default instanceof Expr) {
            $paramType = TypeCombinator::union($paramType, $this->getType($param->default));
        }
        if (!$this->typeComparator->isSubtype($paramType, $propertyType)) {
            return \true;
        }
        return $this->typeComparator->areTypesEqual($propertyType, $paramType);
    }
    private function shouldSkipPropertyAssignedNull(Class_ $class, string $propertyName): bool
    {
        return (bool) $this->betterNodeFinder->findFirst($class, function (Node $node) use ($propertyName): bool {
            if (!$node instanceof Assign) {
                return \false;
            }
            if (!$node->var instanceof PropertyFetch) {
                return \false;
            }
            if (!$this->isName($node->var->var, 'this')) {
                return \false;
            }
            if (!$this->isName($node->var->name, $propertyName)) {
                return \false;
            }
            return $this->valueResolver->isNull($node->expr);
        });
    }
}
