<?php

declare(strict_types=1);

use ast\Node;
use Phan\AST\UnionTypeVisitor;
use Phan\CodeBase;
use Phan\Language\Element\FunctionInterface;
use Phan\Language\Element\Method;
use Phan\Language\FQSEN;
use Phan\Language\UnionType;
use Phan\Library\Map;
use Phan\Library\Set;
use Phan\PluginV3;
use Phan\PluginV3\FinalizeProcessCapability;
use Phan\PluginV3\PluginAwarePostAnalysisVisitor;
use Phan\PluginV3\PostAnalyzeNodeCapability;

/**
 * This plugin checks for return types that can be made more specific.
 *
 * - E.g. `/** (at)return object (*)/ function () { return new ArrayObject(); }`
 *   could be documented as returning an ArrayObject instead.
 *
 * This file demonstrates plugins for Phan. Plugins hook into various events.
 * MoreSpecificElementTypePlugin hooks into two events:
 *
 * - getPostAnalyzeNodeVisitorClassName
 *   This method returns a visitor that is called on every AST node from every
 *   file being analyzed in post-order
 * - finalizeProcess
 *   This is called after the other forms of analysis are finished running.
 *
 * A plugin file must
 *
 * - Contain a class that inherits from \Phan\PluginV3
 *
 * - End by returning an instance of that class.
 *
 * It is assumed without being checked that plugins aren't
 * mangling state within the passed code base or context.
 *
 * Note: When adding new plugins,
 * add them to the corresponding section of README.md
 *
 * TODO: Account for methods in traits being possibly overrides
 *
 * TODO: This does not support intersection types
 */
class MoreSpecificElementTypePlugin extends PluginV3 implements
    PostAnalyzeNodeCapability,
    FinalizeProcessCapability
{
    /** @var Map<FQSEN,ElementTypeInfo> maps function/method/closure FQSEN to function info and the set of union types they return */
    public static $method_return_types;

    /** @var Set<FQSEN> the set of function/method/closure FQSENs that don't need to be more specific. */
    public static $method_blacklist;

    /**
     * @return class-string - name of PluginAwarePostAnalysisVisitor subclass
     */
    public static function getPostAnalyzeNodeVisitorClassName(): string
    {
        return MoreSpecificElementTypeVisitor::class;
    }

    /**
     * Record that $function contains a return statement which returns an expression of type $return_type.
     *
     * This may be called multiple times for the same return statement (Phan recursively analyzes functions with underspecified param types by default)
     */
    public static function recordType(FunctionInterface $function, UnionType $return_type): void
    {
        $fqsen = $function->getFQSEN();
        if (self::$method_blacklist->offsetExists($fqsen)) {
            return;
        }
        if ($return_type->isEmpty()) {
            self::$method_blacklist->attach($fqsen);
            self::$method_return_types->offsetUnset($fqsen);
            return;
        }
        if (self::$method_return_types->offsetExists($fqsen)) {
            self::$method_return_types->offsetGet($fqsen)->types->attach($return_type);
        } else {
            self::$method_return_types->offsetSet($fqsen, new ElementTypeInfo($function, [$return_type]));
        }
    }

    private static function shouldWarnAboutMoreSpecificType(CodeBase $code_base, UnionType $actual_type, UnionType $declared_return_type): bool
    {
        if ($declared_return_type->isEmpty()) {
            // There was no phpdoc type declaration, so let UnknownElementTypePlugin warn about that instead of this.
            // This plugin warns about `@return mixed` but not the absence of a declaration because the former normally prevents phan from inferring something more specific.
            return false;
        }
        if ($declared_return_type->containsNullable() && !$actual_type->containsNullable()) {
            // Warn about `Subclass1|Subclass2` being the real return type of `?BaseClass`
            // because the actual returned type is non-null
            return true;
        }
        if ($declared_return_type->typeCount() === 1) {
            if ($declared_return_type->getTypeSet()[0]->hasObjectWithKnownFQSEN()) {
                if ($actual_type->typeCount() >= 2) {
                    // Don't warn about Subclass1|Subclass2 being more specific than BaseClass
                    return false;
                }
            }
        }
        if ($declared_return_type->isStrictSubtypeOf($code_base, $actual_type)) {
            return false;
        }
        if (!$actual_type->isStrictSubtypeOf($code_base, $declared_return_type)) {
            return false;
        }
        if (!$actual_type->canCastToUnionType($declared_return_type, $code_base)) {
            // Don't warn here about type mismatches such as int->string or object->array, but do warn about SubClass->BaseClass.
            // Phan should warn elsewhere about those mismatches
            return false;
        }
        if ($declared_return_type->hasTopLevelArrayShapeTypeInstances()) {
            return false;
        }
        $real_actual_type = $actual_type->getRealUnionType();
        if (!$real_actual_type->isEmpty() && $declared_return_type->isStrictSubtypeOf($code_base, $real_actual_type)) {
            // TODO: Provide a way to disable this heuristic.
            return false;
        }
        return true;
    }

    private static function containsObjectWithKnownFQSEN(UnionType $union_type): bool
    {
        foreach ($union_type->getTypesRecursively() as $type) {
            if ($type->hasObjectWithKnownFQSEN()) {
                return true;
            }
        }
        return false;
    }

    /**
     * After all return statements are gathered, suggest a more specific type for the various functions.
     */
    public function finalizeProcess(CodeBase $code_base): void
    {
        foreach (self::$method_return_types as $type_info) {
            $function = $type_info->function;
            $function_context = $function->getContext();
            // TODO: Do a better job for Traversable<MyClass> and iterable<MyClass>
            $actual_type = UnionType::merge($type_info->types->toArray())->withStaticResolvedInContext($function_context)->eraseTemplatesRecursive()->asNormalizedTypes();
            $declared_return_type = $function->getOriginalReturnType()->withStaticResolvedInContext($function_context)->eraseTemplatesRecursive()->asNormalizedTypes();
            if (!self::shouldWarnAboutMoreSpecificType($code_base, $actual_type, $declared_return_type)) {
                continue;
            }
            if (self::containsObjectWithKnownFQSEN($actual_type) && !self::containsObjectWithKnownFQSEN($declared_return_type)) {
                $issue_type = 'PhanPluginMoreSpecificActualReturnTypeContainsFQSEN';
                $issue_message = 'Phan inferred that {FUNCTION} documented to have return type {TYPE} (without an FQSEN) returns the more specific type {TYPE} (with an FQSEN)';
            } else {
                $issue_type = 'PhanPluginMoreSpecificActualReturnType';
                $issue_message = 'Phan inferred that {FUNCTION} documented to have return type {TYPE} returns the more specific type {TYPE}';
            }

            $this->emitIssue(
                $code_base,
                $function->getContext(),
                $issue_type,
                $issue_message,
                [
                    $function->getRepresentationForIssue(),
                    $declared_return_type,
                    $actual_type->getDebugRepresentation()
                ]
            );
        }
    }
}

/**
 * Represents the actual return types seen during analysis
 * (including recursive analysis)
 */
class ElementTypeInfo
{
    /** @var FunctionInterface the function with the return values*/
    public $function;
    /** @var Set<UnionType> the set of observed return types */
    public $types;
    /**
     * @param list<UnionType> $return_types
     */
    public function __construct(FunctionInterface $function, array $return_types)
    {
        $this->function = $function;
        $this->types = new Set($return_types);
    }
}
MoreSpecificElementTypePlugin::$method_blacklist = new Set();
MoreSpecificElementTypePlugin::$method_return_types = new Map();

/**
 * This visitor analyzes node kinds that can be the root of expressions
 * containing duplicate expressions, and is called on nodes in post-order.
 */
class MoreSpecificElementTypeVisitor extends PluginAwarePostAnalysisVisitor
{
    /**
     * @param Node $node a node of kind ast\AST_RETURN, representing a return statement.
     */
    public function visitReturn(Node $node): void
    {
        if (!$this->context->isInFunctionLikeScope()) {
            return;
        }
        try {
            $function = $this->context->getFunctionLikeInScope($this->code_base);
        } catch (Exception $_) {
            return;
        }
        if ($function->hasYield()) {
            // TODO: Support analyzing yield key/value types of generators?
            return;
        }
        if ($function instanceof Method) {
            // Skip functions that are overrides or are overridden.
            // They may be documenting a less specific return type to deal with the inheritance hierarchy.
            if ($function->isOverride() || $function->isOverriddenByAnother()) {
                return;
            }
        }
        try {
            // Fetch the list of valid classes, and warn about any undefined classes.
            // (We have more specific issue types such as PhanNonClassMethodCall below, don't emit PhanTypeExpected*)
            $union_type = UnionTypeVisitor::unionTypeFromNode($this->code_base, $this->context, $node->children['expr']);
        } catch (Exception $_) {
            // Phan should already throw for this
            return;
        }
        MoreSpecificElementTypePlugin::recordType($function, $union_type->withFlattenedArrayShapeOrLiteralTypeInstances());
    }
}

// Every plugin needs to return an instance of itself at the
// end of the file in which it's defined.
return new MoreSpecificElementTypePlugin();
