<?php

declare(strict_types=1);

namespace Phan\Language\Element;

use ast;
use Closure;
use Phan\AST\ASTReverter;
use Phan\CodeBase;
use Phan\Exception\IssueException;
use Phan\Issue;
use Phan\Language\Context;
use Phan\Language\FQSEN\FullyQualifiedClassName;
use Phan\Language\FQSEN\FullyQualifiedPropertyName;
use Phan\Language\Scope\PropertyScope;
use Phan\Language\Type\NullType;
use Phan\Language\UnionType;
use TypeError;

/**
 * Phan's representation of a class/trait/interface's property (including magic and dynamic properties)
 * @phan-file-suppress PhanPluginDescriptionlessCommentOnPublicMethod
 * @phan-file-suppress PhanPluginNoCommentOnPublicMethod TODO: Add comments
 * @phan-suppress-next-line PhanParamSignaturePHPDocMismatchReturnType TODO fix https://github.com/phan/phan/issues/3761
 * @method FullyQualifiedPropertyName getDefiningFQSEN()
 */
class Property extends ClassElement
{
    use ElementFutureUnionType;
    use ClosedScopeElement;
    use HasAttributesTrait;

    /**
     * @var ?FullyQualifiedPropertyName If this was originally defined in a trait, this is the trait's defining fqsen.
     * This is tracked separately from getDefiningFQSEN() in order to not break access checks on protected/private properties.
     * Used for dead code detection.
     */
    private $real_defining_fqsen;

    /**
     * @var UnionType The real union type of this property (typed properties were added in PHP 7.4)
     * This does not change.
     */
    private $real_union_type;

    /**
     * @var ?UnionType the phpdoc union type of this property
     */
    private $phpdoc_union_type;

    /**
     * @var ?UnionType the default type
     */
    private $default_type;

    /**
     * @param Context $context
     * The context in which the structural element lives
     *
     * @param string $name
     * The name of the typed structural element
     *
     * @param UnionType $type
     * A '|' delimited set of types satisfied by this
     * typed structural element.
     *
     * @param int $flags
     * The flags property contains node specific flags. It is
     * always defined, but for most nodes it is always zero.
     * ast\kind_uses_flags() can be used to determine whether
     * a certain kind has a meaningful flags value.
     */
    public function __construct(
        Context $context,
        string $name,
        UnionType $type,
        int $flags,
        FullyQualifiedPropertyName $fqsen,
        UnionType $real_union_type
    ) {
        $internal_scope = new PropertyScope(
            $context->getScope(),
            $fqsen
        );
        $context = $context->withScope($internal_scope);
        parent::__construct(
            $context,
            $name,
            $type,
            $flags,
            $fqsen
        );

        // Presume that this is the original definition
        // of this property, and let it be overwritten
        // if it isn't.
        $this->setDefiningFQSEN($fqsen);
        $this->real_defining_fqsen = $fqsen;
        $this->real_union_type = $real_union_type;

        // Set an internal scope, so that issue suppressions can be placed on property doc comments.
        // (plugins acting on properties would then pick those up).
        // $fqsen is used to locate this property.
        $this->setInternalScope($internal_scope);
    }

    /**
     * @return FullyQualifiedPropertyName the FQSEN with the original definition (Even if this is private/protected and inherited from a trait). Used for dead code detection.
     *                                    Inheritance tests use getDefiningFQSEN() so that access checks won't break.
     */
    public function getRealDefiningFQSEN(): FullyQualifiedPropertyName
    {
        return $this->real_defining_fqsen ?? $this->getDefiningFQSEN();
    }

    /**
     * Returns the visibility for this property in context of reading (for issue messages and stubs)
     */
    public function getVisibilityName(): string
    {
        if ($this->isPrivate()) {
            return 'private';
        } elseif ($this->isProtected()) {
            return 'protected';
        } else {
            return 'public';
        }
    }

    /**
     * Returns the visibility for this property in context of setting (for issue messages and stubs)
     */
    public function getVisibilitySetName(): ?string
    {
        if ($this->isPrivateSet()) {
            return 'private';
        } elseif ($this->isProtectedSet()) {
            return 'protected';
        } elseif ($this->isPublicSet()) {
            return 'public';
        }

        return null;
    }

    public function __toString(): string
    {
        $string = $this->getVisibilityName() . ' ';

        $visibilitySet = $this->getVisibilitySetName();
        if (null !== $visibilitySet) {
            $string .= $visibilitySet . '(set) ';
        }

        if ($this->isStatic()) {
            $string .= 'static ';
        }

        // Since the UnionType can be a future, and that
        // can throw an exception, we catch it and ignore it
        try {
            $union_type = $this->getUnionType()->__toString();
            if ($union_type !== '') {
                $string .= "$union_type ";
            } // Don't emit 2 spaces if there is no union type
        } catch (\Exception $_) {
            // do nothing
        }

        $string .= "\${$this->name}";

        return $string;
    }

    /**
     * Returns a representation of the visibility for issue messages.
     */
    public function asVisibilityAndFQSENString(): string
    {
        return $this->getVisibilityName() . ' ' . $this->asPropertyFQSENString();
    }

    /**
     * Returns a representation of the property's FQSEN for issue messages.
     */
    public function asPropertyFQSENString(): string
    {
        return $this->getClassFQSEN()->__toString() .
            ($this->isStatic() ? '::$' : '->') .
            $this->name;
    }

    /**
     * @return string the representation of this FQSEN for issue messages.
     * @override
     */
    public function getRepresentationForIssue(): string
    {
        return $this->asPropertyFQSENString();
    }

    /**
     * Override the default getter to fill in a future
     * union type if available.
     * @throws IssueException if getFutureUnionType fails.
     */
    public function getUnionType(): UnionType
    {
        if (null !== ($union_type = $this->getFutureUnionType())) {
            $this->setUnionType(parent::getUnionType()->withUnionType($union_type->asNonLiteralType()));
        }

        return parent::getUnionType();
    }

    /**
     * @return FullyQualifiedPropertyName
     * The fully-qualified structural element name of this
     * structural element
     * @suppress PhanTypeMismatchReturn
     */
    public function getFQSEN(): FullyQualifiedPropertyName
    {
        return $this->fqsen;
    }

    public function getMarkupDescription(): string
    {
        $string = $this->getVisibilityName() . ' ';

        if ($this->isStatic()) {
            $string .= 'static ';
        }

        $string .= "\${$this->name}";

        return $string;
    }


    /**
     * Returns a stub declaration for this property that can be used to build a class stub
     * in `tool/make_stubs`.
     */
    public function toStub(): string
    {
        $string = '';
        if (self::shouldAddDescriptionsToStubs()) {
            $description = (string)MarkupDescription::extractDescriptionFromDocComment($this);
            if ($description !== '') {
                if ($this->real_union_type->isEmptyOrMixed() && !$this->getUnionType()->isEmptyOrMixed()) {
                    $description = "@var {$this->getUnionType()} $description";
                }
                $string .= MarkupDescription::convertStringToDocComment($description, '    ');
            }
        }
        $string .= '    ' . $this->getVisibilityName() . ' ';

        if ($this->isStatic()) {
            $string .= 'static ';
        }

        $string .= "\${$this->name}";
        $string .= ';';

        return $string;
    }

    /**
     * Used by daemon mode to restore an element to the state it had before parsing.
     * @internal
     */
    public function createRestoreCallback(): ?Closure
    {
        $future_union_type = $this->future_union_type;
        if ($future_union_type === null) {
            // We already inferred the type for this class constant/global constant.
            // Nothing to do.
            return null;
        }
        // If this refers to a class constant in another file,
        // the resolved union type might change if that file changes.
        return function () use ($future_union_type): void {
            $this->future_union_type = $future_union_type;
            // Probably don't need to call setUnionType(mixed) again...
        };
    }

    /**
     * Returns true if at least one of the references to this property was **reading** the property
     *
     * Precondition: Config::get_track_references() === true
     */
    public function hasReadReference(): bool
    {
        return $this->getPhanFlagsHasState(Flags::WAS_PROPERTY_READ);
    }

    public function setHasReadReference(): void
    {
        $this->enablePhanFlagBits(Flags::WAS_PROPERTY_READ);
    }

    /**
     * Returns true if at least one of the references to this property was **writing** the property
     *
     * Precondition: Config::get_track_references() === true
     */
    public function hasWriteReference(): bool
    {
        return $this->getPhanFlagsHasState(Flags::WAS_PROPERTY_WRITTEN);
    }

    public function setHasWriteReference(): void
    {
        $this->enablePhanFlagBits(Flags::WAS_PROPERTY_WRITTEN);
    }

    /**
     * Copy addressable references from an element of the same subclass
     * @override
     */
    public function copyReferencesFrom(AddressableElement $element): void
    {
        if ($this === $element) {
            // Should be impossible
            return;
        }
        if (!($element instanceof Property)) {
            throw new TypeError('Expected $element to be Phan\Language\Element\Property in ' . __METHOD__);
        }
        foreach ($element->reference_list as $key => $file_ref) {
            $this->reference_list[$key] = $file_ref;
        }
        if ($element->hasReadReference()) {
            $this->setHasReadReference();
        }
        if ($element->hasWriteReference()) {
            $this->setHasWriteReference();
        }
    }

    /**
     * @internal
     */
    protected const _IS_DYNAMIC_OR_MAGIC = Flags::IS_FROM_PHPDOC | Flags::IS_DYNAMIC_PROPERTY;

    /**
     * Equivalent to $this->isDynamicProperty() || $this->isFromPHPDoc()
     * i.e. this is a property that is not created from an AST_PROP_ELEM Node.
     */
    public function isDynamicOrFromPHPDoc(): bool
    {
        return ($this->getPhanFlags() & self::_IS_DYNAMIC_OR_MAGIC) !== 0;
    }

    /**
     * @return bool
     * True if this is a magic phpdoc property (declared via (at)property (-read,-write,) on class declaration phpdoc)
     */
    public function isFromPHPDoc(): bool
    {
        return $this->getPhanFlagsHasState(Flags::IS_FROM_PHPDOC);
    }

    /**
     * @param bool $from_phpdoc - True if this is a magic phpdoc property (declared via (at)property (-read,-write,) on class declaration phpdoc)
     * @suppress PhanUnreferencedPublicMethod the caller now just sets all phan flags at once (including IS_READ_ONLY)
     */
    public function setIsFromPHPDoc(bool $from_phpdoc): void
    {
        $this->setPhanFlags(
            Flags::bitVectorWithState(
                $this->getPhanFlags(),
                Flags::IS_FROM_PHPDOC,
                $from_phpdoc
            )
        );
    }

    /**
     * Record whether this property contains `static` anywhere in the original union type.
     */
    public function setHasStaticInUnionType(bool $has_static): void
    {
        $this->setPhanFlags(
            Flags::bitVectorWithState(
                $this->getPhanFlags(),
                Flags::HAS_STATIC_UNION_TYPE,
                $has_static
            )
        );
    }

    /**
     * Does this property contain `static` anywhere in the original union type?
     */
    public function hasStaticInUnionType(): bool
    {
        return $this->getPhanFlagsHasState(Flags::HAS_STATIC_UNION_TYPE);
    }

    /**
     * Was this property undeclared (and created at runtime)?
     */
    public function isDynamicProperty(): bool
    {
        return $this->getPhanFlagsHasState(Flags::IS_DYNAMIC_PROPERTY);
    }

    /**
     * Is this property declared in a way hinting that it should only be written to?
     * (E.g. magic properties declared as (at)property-read, regular properties with (at)phan-read-only)
     */
    public function isReadOnly(): bool
    {
        return $this->getPhanFlagsHasState(Flags::IS_READ_ONLY);
    }

    /**
     * Is this property declared with the PHP 8.1+ `readonly` modifier?
     */
    public function isReadOnlyReal(): bool
    {
        return ($this->getFlags() & ast\flags\MODIFIER_READONLY) !== 0;
    }

    /**
     * Record whether this property is read-only.
     * TODO: Warn about combining IS_READ_ONLY and IS_WRITE_ONLY
     */
    public function setIsReadOnly(bool $is_read_only): void
    {
        $this->setPhanFlags(
            Flags::bitVectorWithState(
                $this->getPhanFlags(),
                Flags::IS_READ_ONLY,
                $is_read_only
            )
        );
    }

    /**
     * Is this property declared in a way hinting that it should only be written to?
     * (E.g. magic properties declared as (at)property-write, regular properties with (at)phan-write-only)
     */
    public function isWriteOnly(): bool
    {
        return $this->getPhanFlagsHasState(Flags::IS_WRITE_ONLY);
    }

    public function setIsDynamicProperty(bool $is_dynamic): void
    {
        $this->setPhanFlags(
            Flags::bitVectorWithState(
                $this->getPhanFlags(),
                Flags::IS_DYNAMIC_PROPERTY,
                $is_dynamic
            )
        );
    }

    public function inheritStaticUnionType(FullyQualifiedClassName $old, FullyQualifiedClassName $new): void
    {
        $union_type = $this->getUnionType();
        foreach ($union_type->getTypeSet() as $type) {
            // TODO: Replace $old in intersection types as well?
            if (!$type->isObjectWithKnownFQSEN()) {
                continue;
            }
            // $type is the name of a class - replace it with $new and preserve nullability.
            if (FullyQualifiedClassName::fromType($type) === $old) {
                $union_type = $union_type
                    ->withoutType($type)
                    ->withType($new->asType()->withIsNullable($type->isNullable()));
            }
        }
        $this->setUnionType($union_type);
    }

    /**
     * @return UnionType|null
     * Get the UnionType from a future union type defined
     * on this object or null if there is no future
     * union type.
     * @override
     * @suppress PhanAccessMethodInternal
     */
    public function getFutureUnionType(): ?UnionType
    {
        $future_union_type = $this->future_union_type;
        if ($future_union_type === null) {
            return null;
        }

        // null out the future_union_type before
        // we compute it to avoid unbounded
        // recursion
        $this->future_union_type = null;

        try {
            $union_type = $future_union_type->get();
            if (!$this->real_union_type->isEmpty()
                && !$union_type->canCastToUnionType($this->real_union_type, $future_union_type->getCodeBase())) {
                    Issue::maybeEmit(
                        $future_union_type->getCodeBase(),
                        $future_union_type->getContext(),
                        Issue::TypeMismatchPropertyDefaultReal,
                        $future_union_type->getContext()->getLineNumberStart(),
                        $this->real_union_type,
                        $this->name,
                        ASTReverter::toShortString($future_union_type->getNode()),
                        $union_type->asNonLiteralType()  // Make issue messages less verbose for property declarations. Same as ParseVisitor::addProperty
                    );
            }
        } catch (IssueException $_) {
            $union_type = UnionType::empty();
        }

        // Don't set 'null' as the type if that's the default
        // given that its the default.
        if ($union_type->isType(NullType::instance(false))) {
            $union_type = UnionType::empty();
        } else {
            $union_type = $union_type->eraseRealTypeSetRecursively();
        }

        return $union_type->withRealTypeSet($this->real_union_type->getTypeSet());
    }

    public function getRealUnionType(): UnionType
    {
        return $this->real_union_type;
    }

    public function setPHPDocUnionType(UnionType $type): void
    {
        $this->phpdoc_union_type = $type;
    }

    public function getPHPDocUnionType(): UnionType
    {
        return $this->phpdoc_union_type ?? UnionType::empty();
    }

    /**
     * Record the union type of the default value (for declared properties)
     */
    public function setDefaultType(UnionType $type): void
    {
        $this->default_type = $type;
    }

    /**
     * Return the recorded union type of the default value (for declared properties).
     * This is null if there is no declared type.
     * (TODO: Consider ways to represent an "undefined" state for php 7.4 typed properties)
     */
    public function getDefaultType(): ?UnionType
    {
        return $this->default_type;
    }

    /**
     * Clone this, substituting the given types for template types in our type.
     * @param array<string,UnionType> $template_type_map
     * A map from template type identifier to a concrete type
     */
    public function cloneWithTemplateParameterTypeMap(array $template_type_map): self
    {
        $property = clone($this);

        if (
            !$property->hasUnresolvedFutureUnionType()
            && $property->getUnionType()->hasTemplateTypeRecursive()
        ) {
            $property->setUnionType(
                $property->getUnionType()->withTemplateParameterTypeMap($template_type_map)
            );
        }

        // Map the property's PHPDoc type as well
        // (the final union type may not have been computed yet)
        if ($property->getPHPDocUnionType()->hasTemplateTypeRecursive()) {
            $property->setPHPDocUnionType(
                $property->getPHPDocUnionType()->withTemplateParameterTypeMap($template_type_map)
            );
        }

        return $property;
    }

    /**
     * True if this is a property publicly settable
     */
    public function isPublicSet(): bool
    {
        return $this->getFlagsHasState(\ast\flags\MODIFIER_PUBLIC_SET);
    }

    /**
     * True if this is a property protected settable
     */
    public function isProtectedSet(): bool
    {
        return $this->getFlagsHasState(\ast\flags\MODIFIER_PROTECTED_SET);
    }

    /**
     * True if this is a property privately settable
     */
    public function isPrivateSet(): bool
    {
        return $this->getFlagsHasState(\ast\flags\MODIFIER_PRIVATE_SET);
    }

    /**
     * @param CodeBase $code_base used for access checks to protected properties
     * @param ?FullyQualifiedClassName $accessing_class_fqsen the class FQSEN of the current scope.
     *                                    null if in the global scope.
     * @param bool $forRead context for accessibility
     * @return bool true if this can be accessed from the scope of $accessing_class_fqsen
     */
    public function isAccessibleFromClass(
        CodeBase $code_base,
        ?FullyQualifiedClassName $accessing_class_fqsen,
        bool $forRead = true
    ): bool {
        // Asymmetric visibility
        if (!$forRead && ($this->isPrivateSet() || $this->isProtectedSet())) {
            if (!$accessing_class_fqsen) {
                // private(set) and protected(set) can not be accessed from outside class scope
                return false;
            }

            if ($this->isAccessedFromSameClass($accessing_class_fqsen)) {
                return true;
            }

            if ($this->isPrivateSet()) {
                return $this->isPrivatelyAccessibleByClass($code_base, $accessing_class_fqsen);
            }

            if ($this->isProtectedSet()) {
                return $this->checkCanAccessProtectedElement($code_base, $accessing_class_fqsen);
            }
        }

        return parent::isAccessibleFromClass($code_base, $accessing_class_fqsen, $forRead);
    }
}
