<?php

declare(strict_types=1);

namespace Phan\AST;

use ast;
use ast\Node;
use Exception;
use Phan\CodeBase;
use Phan\Exception\CodeBaseException;
use Phan\Exception\NodeException;
use Phan\Language\Context;
use Phan\Language\Element\Clazz;
use Phan\Language\Element\FunctionInterface;
use Phan\Language\FQSEN\FullyQualifiedClassName;
use Phan\Language\FQSEN\FullyQualifiedFunctionName;
use Phan\Plugin\Internal\UseReturnValuePlugin;
use Phan\Plugin\Internal\UseReturnValuePlugin\PureMethodGraph;
use Phan\Plugin\Internal\UseReturnValuePlugin\UseReturnValueVisitor;

use function is_string;

/**
 * Used to check if a method is pure.
 * Throws NodeException if it sees a node that isn't likely to be in a method that is free of side effects.
 *
 * This ignores many edge cases, including:
 * - Magic properties
 * - The possibility of emitting notices or throwing
 * - Whether or not referenced elements exist (Phan checks that elsewhere)
 *
 * @phan-file-suppress PhanThrowTypeAbsent
 */
class InferPureVisitor extends AnalysisVisitor
{
    /** @var string the function fqsen being visited */
    protected $function_fqsen_label;

    /**
     * Map from labels to functions which this had called, but were not certain to have been pure.
     *
     * @var array<string, FunctionInterface>
     */
    protected $unresolved_status_dependencies = [];

    /**
     * A graph that tracks information about whether functions are pure, and how they depend on other functions.
     *
     * @var ?PureMethodGraph
     */
    protected $pure_method_graph;

    public function __construct(CodeBase $code_base, Context $context, string $function_fqsen_label, ?PureMethodGraph $graph = null)
    {
        $this->code_base = $code_base;
        $this->context = $context;
        $this->function_fqsen_label = $function_fqsen_label;
        $this->pure_method_graph = $graph;
    }

    /**
     * Generate a visitor from a function or method.
     * This will be used for checking if the method is pure.
     */
    public static function fromFunction(CodeBase $code_base, FunctionInterface $func, ?PureMethodGraph $graph): InferPureVisitor
    {
        return new self(
            $code_base,
            $func->getContext(),
            self::getLabelForFunction($func),
            $graph
        );
    }

    /**
     * Returns the label UseReturnValuePlugin will use to look up whether this functions/methods is pure.
     */
    public function getLabel(): string
    {
        return $this->function_fqsen_label;
    }

    /**
     * Returns an array of functions/methods with unknown pure status.
     * If any of those functions are impure, then this function is impure.
     *
     * @return array<string, FunctionInterface>
     */
    public function getUnresolvedStatusDependencies(): array
    {
        return $this->unresolved_status_dependencies;
    }

    /**
     * Returns the label UseReturnValuePlugin will use to look up whether functions/methods are pure.
     */
    public static function getLabelForFunction(FunctionInterface $func): string
    {
        return \strtolower(\ltrim($func->getFQSEN()->__toString(), '\\'));
    }

    // visitAssignRef
    // visitThrow
    // visitEcho
    // visitPrint
    // visitIncludeOrExec
    public function visit(Node $node): void
    {
        throw new NodeException($node);
    }

    public function visitVar(Node $node): void
    {
        if (!\is_scalar($node->children['name'])) {
            throw new NodeException($node);
        }
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitClassName(Node $node): void
    {
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitMagicConst(Node $node): void
    {
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitConst(Node $node): void
    {
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitEmpty(Node $node): void
    {
        $this->maybeInvoke($node->children['expr']);
    }

    /** @override */
    public function visitIsset(Node $node): void
    {
        $this->maybeInvoke($node->children['var']);
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitContinue(Node $node): void
    {
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitBreak(Node $node): void
    {
    }

    /** @override */
    public function visitClassConst(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    public function visitStatic(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    public function visitArray(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    public function visitArrayElem(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    public function visitEncapsList(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    public function visitInstanceof(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    public function visitPreInc(Node $node): void
    {
        $this->checkPureIncDec($node);
    }

    public function visitPreDec(Node $node): void
    {
        $this->checkPureIncDec($node);
    }

    public function visitPostInc(Node $node): void
    {
        $this->checkPureIncDec($node);
    }

    public function visitPostDec(Node $node): void
    {
        $this->checkPureIncDec($node);
    }

    protected function checkPureIncDec(Node $node): void
    {
        $var = $node->children['var'];
        if (!$var instanceof Node) {
            throw new NodeException($node);
        }
        if ($var->kind !== ast\AST_VAR) {
            throw new NodeException($var);
        }
        $this->visitVar($var);
    }

    /**
     * @param Node|string|int|float|null $node
     */
    final protected function maybeInvoke($node): void
    {
        if ($node instanceof Node) {
            $this->__invoke($node);
        }
    }

    public function visitBinaryOp(Node $node): void
    {
        $this->maybeInvoke($node->children['left']);
        $this->maybeInvoke($node->children['right']);
    }

    public function visitUnaryOp(Node $node): void
    {
        $this->maybeInvoke($node->children['expr']);
    }

    public function visitDim(Node $node): void
    {
        $this->maybeInvoke($node->children['expr']);
        $this->maybeInvoke($node->children['dim']);
    }

    public function visitNullsafeProp(Node $node): void
    {
        $this->visitProp($node);
    }

    public function visitProp(Node $node): void
    {
        ['expr' => $expr, 'prop' => $prop] = $node->children;
        if (!$expr instanceof Node) {
            throw new NodeException($node);
        }
        $this->__invoke($expr);
        if ($prop instanceof Node) {
            throw new NodeException($prop);
        }
    }

    /** @override */
    public function visitStmtList(Node $node): void
    {
        foreach ($node->children as $stmt) {
            if ($stmt instanceof Node) {
                $this->__invoke($stmt);
            }
        }
    }

    /** @override */
    public function visitStaticProp(Node $node): void
    {
        ['class' => $class, 'prop' => $prop] = $node->children;
        if (!$class instanceof Node) {
            throw new NodeException($node);
        }
        $this->__invoke($class);
        if ($prop instanceof Node) {
            throw new NodeException($prop);
        }
    }

    final protected function maybeInvokeAllChildNodes(Node $node): void
    {
        foreach ($node->children as $c) {
            if ($c instanceof Node) {
                $this->__invoke($c);
            }
        }
    }

    /** @override */
    public function visitCast(Node $node): void
    {
        $this->maybeInvoke($node->children['expr']);
    }

    /** @override */
    public function visitConditional(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitWhile(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitDoWhile(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitFor(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitForeach(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitIf(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitIfElem(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitSwitch(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitSwitchList(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitSwitchCase(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitMatch(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitMatchArmList(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitMatchArm(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /** @override */
    public function visitExprList(Node $node): void
    {
        $this->maybeInvokeAllChildNodes($node);
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitGoto(Node $node): void
    {
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitLabel(Node $node): void
    {
    }

    /** @override */
    public function visitAssignOp(Node $node): void
    {
        $this->visitAssign($node);
    }

    /** @override */
    public function visitAssign(Node $node): void
    {
        ['var' => $var, 'expr' => $expr] = $node->children;
        if (!$var instanceof Node) {
            throw new NodeException($node);
        }
        $this->checkVarKindOfAssign($var);
        $this->__invoke($var);
        if ($expr instanceof Node) {
            $this->__invoke($expr);
        }
    }

    private function checkVarKindOfAssign(Node $var): void
    {
        if ($var->kind === ast\AST_VAR) {
            return;
        } elseif ($var->kind === ast\AST_PROP) {
            // Functions that assign to properties aren't pure,
            // unless assigning to $this->prop in a constructor.
            if (\preg_match('/::__construct$/iD', $this->function_fqsen_label)) {
                $name = $var->children['expr'];
                if ($name instanceof Node && $name->kind === ast\AST_VAR && $name->children['name'] === 'this') {
                    return;
                }
            }
        }
        throw new NodeException($var);
    }

    public function visitNew(Node $node): void
    {
        $name_node = $node->children['class'];
        if (!($name_node instanceof Node && $name_node->kind === ast\AST_NAME)) {
            throw new NodeException($node);
        }
        // "Fatal error: Cannot create Closure for new expression" (for AST_CALLABLE_CONVERT) is caught elsewhere
        $this->__invoke($node->children['args']);
        try {
            $class_list = (new ContextNode($this->code_base, $this->context, $name_node))->getClassList(false, ContextNode::CLASS_LIST_ACCEPT_OBJECT_OR_CLASS_NAME);
        } catch (Exception $_) {
            throw new NodeException($name_node);
        }
        if (!$class_list) {
            throw new NodeException($name_node, 'no class found');
        }
        foreach ($class_list as $class) {
            if ($class->isPHPInternal()) {
                // TODO build a list of internal classes where result of new() is often unused.
                continue;
            }
            if (!$class->hasMethodWithName($this->code_base, '__construct', true)) {
                throw new NodeException($name_node, 'no __construct found');
            }
            $this->checkCalledFunction($node, $class->getMethodByName($this->code_base, '__construct'));
        }
    }

    /** @override */
    public function visitReturn(Node $node): void
    {
        $expr_node = $node->children['expr'];
        if ($expr_node instanceof Node) {
            $this->__invoke($expr_node);
        }
    }

    /** @override */
    public function visitYield(Node $node): void
    {
        $this->maybeInvoke($node->children['key']);
        $this->maybeInvoke($node->children['value']);
    }

    /** @override */
    public function visitYieldFrom(Node $node): void
    {
        $this->maybeInvoke($node->children['expr']);
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitName(Node $node): void
    {
        // do nothing
    }

    /** @override */
    public function visitCall(Node $node): void
    {
        $expr = $node->children['expr'];
        if (!$expr instanceof Node) {
            throw new NodeException($node);
        }
        if ($expr->kind !== ast\AST_NAME) {
            // XXX this is deliberately a limited subset of what full analysis would do,
            // so this can't infer locally set closures, etc.
            throw new NodeException($expr);
        }
        $found_function = false;
        try {
            $function_list_generator = (new ContextNode(
                $this->code_base,
                $this->context,
                $expr
            ))->getFunctionFromNode(true);

            foreach ($function_list_generator as $function) {
                $this->checkCalledFunction($node, $function);
                $found_function = true;
            }
        } catch (CodeBaseException $_) {
            // ignore it.
        }
        if (!$found_function) {
            throw new NodeException($expr, 'not a function');
        }
        $this->__invoke($node->children['args']);
    }

    public function visitStaticCall(Node $node): void
    {
        $method = $node->children['method'];
        if (!\is_string($method)) {
            throw new NodeException($node);
        }
        $class = $node->children['class'];
        if (!($class instanceof Node)) {
            throw new NodeException($node);
        }
        if ($class->kind !== ast\AST_NAME) {
            throw new NodeException($class, 'not a name');
        }
        try {
            $union_type = UnionTypeVisitor::unionTypeFromClassNode(
                $this->code_base,
                $this->context,
                $class
            );
        } catch (Exception $_) {
            throw new NodeException($class, 'could not get type');
        }
        // TODO: Check all classes in union and intersection types instead up to a limit?
        if ($union_type->typeCount() !== 1) {
            throw new NodeException($class);
        }
        $type = $union_type->getTypeSet()[0];
        if (!$type->isObjectWithKnownFQSEN()) {
            throw new NodeException($class);
        }
        $class_fqsen = $type->asFQSEN();
        if (!($class_fqsen instanceof FullyQualifiedClassName)) {
            throw new NodeException($class);
        }
        if (!$this->code_base->hasClassWithFQSEN($class_fqsen)) {
            throw new NodeException($class);
        }
        try {
            $class = $this->code_base->getClassByFQSEN($class_fqsen);
        } catch (Exception $_) {
            throw new NodeException($node);
        }
        if (!$class->hasMethodWithName($this->code_base, $method, true)) {
            throw new NodeException($node, 'no method');
        }

        $this->checkCalledFunction($node, $class->getMethodByName($this->code_base, $method));
        $this->__invoke($node->children['args']);
    }

    public function visitNullsafeMethodCall(Node $node): void
    {
        $this->visitMethodCall($node);
    }

    public function visitMethodCall(Node $node): void
    {
        $method_name = $node->children['method'];
        if (!\is_string($method_name)) {
            throw new NodeException($node);
        }
        $expr = $node->children['expr'];
        if (!$expr instanceof Node) {
            throw new NodeException($node);
        }
        $class = $this->getClassForVariable($expr);
        if (!$class->hasMethodWithName($this->code_base, $method_name, true)) {
            throw new NodeException($expr, 'does not have method');
        }
        $this->checkCalledFunction($node, $class->getMethodByName($this->code_base, $method_name));

        $this->__invoke($node->children['args']);
    }

    protected function getClassForVariable(Node $expr): Clazz
    {
        if (!$this->context->isInClassScope()) {
            // We don't track variables in UseReturnValuePlugin
            throw new NodeException($expr, 'method call seen outside class scope');
        }
        if ($expr->kind !== ast\AST_VAR) {
            throw new NodeException($expr, 'expected simple variable');
        }

        $var_name = $expr->children['name'];
        if (!is_string($var_name)) {
            // TODO: Support static properties, (new X()), other expressions with inferable types
            throw new NodeException($expr, 'variable name is not a string');
        }
        if ($var_name !== 'this') {
            throw new NodeException($expr, 'not $this');
        }
        if (!$this->context->isInClassScope()) {
            throw new NodeException($expr, 'Not in class scope');
        }
        return $this->context->getClassInScope($this->code_base);
    }

    /**
     * @param Node $node the node of the call, with 'args'
     */
    protected function checkCalledFunction(Node $node, FunctionInterface $method): void
    {
        if ($method->isPure()) {
            return;
        }
        $label = self::getLabelForFunction($method);

        $value = (UseReturnValuePlugin::HARDCODED_FQSENS[$label] ?? false);
        if ($value === true) {
            return;
        } elseif ($value === UseReturnValuePlugin::SPECIAL_CASE) {
            if (UseReturnValueVisitor::doesSpecialCaseHaveSideEffects($label, $node)) {
                // infer that var_export($x, true) is pure but not var_export($x)
                throw new NodeException($node, $label);
            }
            return;
        }
        if ($method->isPHPInternal()) {
            // Something such as printf that isn't pure. Or something that isn't in the HARDCODED_FQSENS.
            throw new NodeException($node, 'internal method is not pure');
        }
        if ($label === $this->function_fqsen_label) {
            return;
        }
        if ($this->pure_method_graph) {
            $this->unresolved_status_dependencies[$label] = $method;
            return;
        }
        throw new NodeException($node, $label);
    }

    public function visitClosure(Node $node): void
    {
        $closure_fqsen = FullyQualifiedFunctionName::fromClosureInContext(
            (clone($this->context))->withLineNumberStart($node->lineno),
            $node
        );
        if (!$this->code_base->hasFunctionWithFQSEN($closure_fqsen)) {
            throw new NodeException($node, "Failed lookup of closure_fqsen");
        }
        $this->checkCalledFunction($node, $this->code_base->getFunctionByFQSEN($closure_fqsen));
    }

    public function visitArrowFunc(Node $node): void
    {
        $this->visitClosure($node);
    }

    public function visitArgList(Node $node): void
    {
        foreach ($node->children as $x) {
            if ($x instanceof Node) {
                $this->__invoke($x);
            }
        }
    }

    /**
     * @unused-param $node
     * @override
     */
    public function visitCallableConvert(Node $node): void
    {
    }

    public function visitNamedArg(Node $node): void
    {
        $expr = $node->children['expr'];
        if ($expr instanceof Node) {
            $this->__invoke($expr);
        }
    }
}
