<?php declare(strict_types=1);

namespace Phan\Analysis;

use AssertionError;
use ast\Node;
use Phan\AST\Visitor\KindVisitorImplementation;

use function count;

/**
 * This checks what exit statuses are possible for AST nodes: `break;`, `continue;`, `throw`, `return`, proceeding, etc.
 *
 * This caches the status for AST nodes, so references to this object
 * should be removed once the source transformation of a file/function is complete.
 *
 * This uses the \ast\Node's themselves in order to cache the status.
 * It reuses $node->flags whenever possible in order to avoid keeping around \ast\Node
 * instances for longer than those would be used.
 * This assumes that Nodes aren't manipulated, or manipulations to Nodes will preserve the semantics (including computed exit status) or clear $node->flags.
 *
 * - Creating an additional object property would increase overall memory usage, which is why properties are used.
 * - AST_IF, AST_IF_ELEM, AST_DO_WHILE, AST_FOR, AST_WHILE, AST_STMT_LIST,
 *   etc (e.g. switch and switch case, try/finally).
 *   are node types which are known to not have flags in AST version 40.
 * - In the future, add a new property such as $node->children['__exitStatus'] if used for a node type with flags, or use the higher bits.
 *
 * TODO: Change to AnalysisVisitor if this ever emits issues.
 * TODO: Analyze switch (if there is a default) in another PR (And handle fallthrough)
 *
 * @phan-file-suppress PhanUnusedPublicFinalMethodParameter
 * @phan-file-suppress PhanPartialTypeMismatchArgument
 */
final class BlockExitStatusChecker extends KindVisitorImplementation
{
    // These should be at most 1 << 31, in order to work in 32-bit php.
    // NOTE: Any exit status must be a combination of at least one of these bits
    // E.g. if STATUS_PROCEED is mixed with STATUS_RETURN, it would mean it is possible both to go to completion or return.
    const STATUS_PROCEED        = (1 << 20);       // At least one branch continues to completion.
    const STATUS_GOTO           = (1 << 21);       // At least one branch leads to a goto statement
    const STATUS_CONTINUE       = (1 << 22);       // At least one branch leads to a continue statement
    const STATUS_BREAK          = (1 << 23);       // At least one branch leads to a break statement
    const STATUS_THROW          = (1 << 24);       // At least one branch leads to a throw statement
    const STATUS_RETURN         = (1 << 25);       // At least one branch leads to a return/exit() statement (or an infinite loop)

    const STATUS_THROW_OR_RETURN_BITMASK =
        self::STATUS_THROW |
        self::STATUS_RETURN;

    // Any status which doesn't lead to proceeding.
    const STATUS_NOT_PROCEED_BITMASK =
        self::STATUS_GOTO |
        self::STATUS_CONTINUE |
        self::STATUS_BREAK |
        self::STATUS_THROW |
        self::STATUS_RETURN;

    const STATUS_BITMASK =
        self::STATUS_PROCEED |
        self::STATUS_NOT_PROCEED_BITMASK;

    const STATUS_MAYBE_PROCEED =
        self::STATUS_PROCEED |
        self::STATUS_GOTO;

    public function __construct()
    {
    }

    /**
     * Computes the bitmask representing the possible ways this block of code might exit.
     *
     * This currently does not handle goto or `break N` comprehensively.
     */
    public function check(Node $node = null) : int
    {
        if (!$node) {
            return self::STATUS_PROCEED;
        }
        $result = $this->__invoke($node);
        if (!\is_int($result) || $result <= 0) {
            throw new AssertionError('Expected positive int');
        }
        return $result;
    }

    /**
     * If we don't know how to analyze a node type (or left it out), assume it always proceeds
     * @return int - The status bitmask corresponding to always proceeding
     */
    public function visit(Node $node)
    {
        return self::STATUS_PROCEED;
    }

    /**
     * @param Node|string|int|float $cond
     */
    private static function isTruthyLiteral($cond) : bool
    {
        if ($cond instanceof Node) {
            // TODO: Could look up values for remaining constants and inline expressions, but doing that has low value.
            if ($cond->kind === \ast\AST_CONST) {
                $cond_name = $cond->children['name'];
                if ($cond_name->kind === \ast\AST_NAME) {
                    $cond_name_string = $cond_name->children['name'];
                    return \is_string($cond_name_string) && \strcasecmp($cond_name_string, 'true') === 0;
                }
            }
            return false;
        }
        // Cast string, int, etc. literal to a bool
        return (bool)$cond;
    }

    /**
     * A break statement unconditionally breaks out of a loop/switch
     * @return int the corresponding status code
     */
    public function visitBreak(Node $node)
    {
        return self::STATUS_BREAK;
    }

    /**
     * A continue statement unconditionally continues out of a loop/switch.
     * TODO: Make this account for levels
     * @return int the corresponding status code
     */
    public function visitContinue(Node $node)
    {
        return self::STATUS_CONTINUE;
    }

    /**
     * A throw statement unconditionally throws
     * @return int the corresponding status code
     */
    public function visitThrow(Node $node)
    {
        return self::STATUS_THROW;
    }

    /**
     * @return int the corresponding status code for the try/catch/finally block
     */
    public function visitTry(Node $node)
    {
        $status = $node->flags & self::STATUS_BITMASK;
        if ($status) {
            return $status;
        }
        $status = $this->computeStatusOfTry($node);
        $node->flags = $status;
        return $status;
    }

    private function computeStatusOfTry(Node $node) : int
    {
        $main_status = $this->check($node->children['try']);
        // Finding good heuristics is difficult.
        // e.g. "return someFunctionThatMayThrow()" in try{} block would be inferred as STATUS_RETURN, but may actually be STATUS_THROW

        $finally_node = $node->children['finally'];
        if ($finally_node) {
            $finally_status = $this->check($finally_node);
            // TODO: Could emit an issue as a side effect
            // Having any sort of status in a finally statement is
            // likely to have unintuitive behavior.
            if ($finally_status & (~self::STATUS_THROW_OR_RETURN_BITMASK) === 0) {
                return $finally_status;
            }
        } else {
            $finally_status = self::STATUS_PROCEED;
        }
        $catches_node = $node->children['catches'];
        if (\count($catches_node->children) == 0) {
            return self::mergeFinallyStatus($main_status, $finally_status);
        }
        // TODO: Could enhance slightly by checking for catch nodes with the exact same types (or subclasses) as names of exception thrown.
        $combined_status = self::mergeFinallyStatus($main_status, $finally_status) | $this->visitCatchList($catches_node);
        if (($finally_status & self::STATUS_PROCEED) === 0) {
            $combined_status &= ~self::STATUS_PROCEED;
        }
        // No idea.
        return $combined_status;
    }

    /**
     * @return int the corresponding status code
     */
    public function visitCatchList(Node $node)
    {
        $status = $node->flags & self::STATUS_BITMASK;
        if ($status) {
            return $status;
        }
        $status = $this->computeStatusOfCatchList($node);
        $node->flags = $status;
        return $status;
    }

    private function computeStatusOfCatchList(Node $node) : int
    {
        $catch_list = $node->children;
        if (count($catch_list) === 0) {
            return self::STATUS_PROCEED;  // status probably won't matter
        }
        // TODO: Could enhance slightly by checking for catch nodes with the exact same types (or subclasses) as names of exception thrown.
        $combined_status = 0;
        // Try to cover all possible cases, such as try { return throwsException(); } catch(Exception $e) { break; }
        foreach ($node->children as $catch_node) {
            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable this is never null for catch nodes
            $catch_node_status = $this->visitStmtList($catch_node->children['stmts']);
            $combined_status |= $catch_node_status;
        }
        // No idea.
        return $combined_status;
    }

    private static function mergeFinallyStatus(int $try_status, int $finally_status) : int
    {
        // If at least one of try or finally are guaranteed not to proceed to completion,
        // then combine those possibilities.
        if (($try_status & $finally_status & self::STATUS_PROCEED) === 0) {
            return ($try_status | $finally_status) & ~self::STATUS_PROCEED;
        }
        return $try_status | $finally_status;
    }

    /**
     * @return int the corresponding status code
     */
    public function visitSwitch(Node $node)
    {
        $status = $node->flags & self::STATUS_BITMASK;
        if ($status) {
            return $status;
        }
        $status = $this->computeStatusOfSwitch($node);
        $node->flags = $status;
        return $status;
    }

    private function computeStatusOfSwitch(Node $node) : int
    {
        $switch_stmt_case_nodes = $node->children['stmts']->children;
        if (\count($switch_stmt_case_nodes) === 0) {
            return self::STATUS_PROCEED;
        }
        $has_default = false;
        $combined_statuses = 0;
        foreach ($switch_stmt_case_nodes as $index => $case_node) {
            if ($case_node->children['cond'] === null) {
                $has_default = true;
            }
            $case_status = self::getStatusOfSwitchCase($case_node, $index, $switch_stmt_case_nodes);
            if (($case_status & self::STATUS_CONTINUE_OR_BREAK) !== 0) {
                // Ignore statuses such as break/continue. They take effect inside, but are a proceed status outside
                $case_status = ($case_status & ~self::STATUS_CONTINUE_OR_BREAK) | self::STATUS_PROCEED;
            }
            $combined_statuses |= $case_status;
        }
        if (!$has_default) {
            $combined_statuses |= self::STATUS_PROCEED;
        }
        return $combined_statuses;
    }

    /**
     * @param array<int,Node> $siblings
     */
    private function getStatusOfSwitchCase(Node $case_node, int $index, array $siblings) : int
    {
        $status = $case_node->flags & self::STATUS_BITMASK;
        if ($status) {
            return $status;
        }
        $status = $this->computeStatusOfSwitchCase($case_node, $index, $siblings);
        $case_node->flags = $status;
        return $status;
    }

    /**
     * @param array<mixed,Node|int|string|float> $siblings
     */
    private function computeStatusOfSwitchCase(Node $case_node, int $index, array $siblings) : int
    {
        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable this is never null
        $status = $this->visitStmtList($case_node->children['stmts']);
        if (($status & self::STATUS_PROCEED) === 0) {
            // Check if the current switch case will not fall through.
            return $status;
        }
        $next_sibling = $siblings[$index + 1] ?? null;
        if (!$next_sibling) {
            return $status;
        }
        $next_status = self::getStatusOfSwitchCase($next_sibling, $index + 1, $siblings);
        // Combine the possibilities.
        // e.g. `case 1: if (cond()) { return; } case 2: throw;`, case 1 will either break or throw,
        // but won't proceed normally to the outside of the switch statement.
        return ($status & ~self::STATUS_PROCEED) | $next_status;
    }

    const UNEXITABLE_LOOP_INNER_STATUS = self::STATUS_PROCEED | self::STATUS_CONTINUE;

    const STATUS_CONTINUE_OR_BREAK = self::STATUS_CONTINUE | self::STATUS_BREAK;

    /**
     * @return int
     */
    public function visitForeach(Node $node)
    {
        // We assume foreach loops are over a finite sequence, and that it's possible for that sequence to have at least one element.
        $inner_status = $this->check($node->children['stmts']);


        // 1. break/continue apply to the inside of a loop, not outside. Not going to analyze "break 2;", may emit an info level issue in the future.
        // 2. We assume that it's possible that any given loop can have 0 iterations.
        //    A TODO exists above to check for special cases.
        return ($inner_status & ~self::STATUS_CONTINUE_OR_BREAK) | self::STATUS_PROCEED;
    }

    /**
     * @return int
     */
    public function visitWhile(Node $node)
    {
        $inner_status = $this->check($node->children['stmts']);
        // TODO: Check for unconditionally false conditions.
        if (self::isTruthyLiteral($node->children['cond'])) {
            // Use a special case to analyze "while (1) {exprs}" or "for (; true; ) {exprs}"
            // TODO: identify infinite loops, mark those as STATUS_NO_PROCEED or STATUS_RETURN.
            return self::computeDerivedStatusOfInfiniteLoop($inner_status);
        }
        // This is (to our awareness) **not** an infinite loop


        // 1. break/continue apply to the inside of a loop, not outside. Not going to analyze "break 2;", may emit an info level issue in the future.
        // 2. We assume that it's possible that any given loop can have 0 iterations.
        //    A TODO exists above to check for special cases.
        return ($inner_status & ~self::STATUS_CONTINUE_OR_BREAK) | self::STATUS_PROCEED;
    }

    /**
     * @return int the corresponding status code
     */
    public function visitFor(Node $node)
    {
        $inner_status = $this->check($node->children['stmts']);
        // for loops have an expression list as a condition.
        $cond_nodes = $node->children['cond']->children ?? [];  // NOTE: $node->children['cond'] is null for the expression `for (;;)`
        // TODO: Check for unconditionally false conditions.
        if (count($cond_nodes) === 0 || self::isTruthyLiteral(\end($cond_nodes))) {
            // Use a special case to analyze "while (1) {exprs}" or "for (; true; ) {exprs}"
            // TODO: identify infinite loops, mark those as STATUS_NO_PROCEED or STATUS_RETURN.
            return self::computeDerivedStatusOfInfiniteLoop($inner_status);
        }
        // This is (to our awareness) **not** an infinite loop


        // 1. break/continue apply to the inside of a loop, not outside. Not going to analyze "break 2;", may emit an info level issue in the future.
        // 2. We assume that it's possible that any given loop can have 0 iterations.
        //    A TODO exists above to check for special cases.
        return ($inner_status & ~self::STATUS_CONTINUE_OR_BREAK) | self::STATUS_PROCEED;
        // TODO: Improve this by checking for loops which almost definitely have at least one iteration,
        // such as "foreach ([$val] as $v)" or "for ($i = 0; $i < 10; $i++)"

        // if (($inner_status & ~self::STATUS_THROW_OR_RETURN_BITMASK) === 0) {
        //     // The inside of the loop will unconditionally throw or return.
        //     return $inner_status
        // }
    }

    // Logic to determine status of "while (1) {exprs}" or "for (; true; ) {exprs}"
    // TODO: identify infinite loops, mark those as STATUS_NO_PROCEED or STATUS_RETURN.
    private static function computeDerivedStatusOfInfiniteLoop(int $inner_status) : int
    {
        $status = $inner_status & ~self::UNEXITABLE_LOOP_INNER_STATUS;
        if ($status === 0) {
            return self::STATUS_RETURN;  // this is an infinite loop, it didn't contain break/throw/return statements?
        }
        if (($status & self::STATUS_BREAK) !== 0) {
            // if the inside of "while (true) {} contains a break statement,
            // then execution can proceed past the end of the loop.
            return ($status & ~self::STATUS_BREAK) | self::STATUS_PROCEED;
        }
        return $status;
    }

    /**
     * A return statement unconditionally returns (Assume expression passed in doesn't throw)
     * @return int the corresponding status code
     */
    public function visitReturn(Node $node)
    {
        return self::STATUS_RETURN;
    }

    /**
     * An exit statement unconditionally exits (Assume expression passed in doesn't throw)
     * @return int the corresponding status code
     */
    public function visitExit(Node $node)
    {
        return self::STATUS_RETURN;
    }

    /**
     * @return int the corresponding status code
     */
    public function visitUnaryOp(Node $node)
    {
        // Don't modify $node->flags, use unmodified flags here
        if ($node->flags !== \ast\flags\UNARY_SILENCE) {
            return self::STATUS_PROCEED;
        }
        // Analyze exit status of `@expr` like `expr` (e.g. @trigger_error())
        $expr = $node->children['expr'];
        if (!($expr instanceof Node)) {
            return self::STATUS_PROCEED;
        }
        return $this->__invoke($expr);
    }

    /**
     * Determines the exit status of a function call, such as trigger_error()
     *
     * NOTE: A trigger_error() statement may or may not exit, depending on the constant and user configuration.
     * @return int the corresponding status code
     */
    public function visitCall(Node $node)
    {
        $status = $node->flags & self::STATUS_BITMASK;
        if ($status) {
            return $status;
        }
        $status = $this->computeStatusOfCall($node);
        $node->flags = $status;
        return $status;
    }

    private function computeStatusOfCall(Node $node) : int
    {
        $expression = $node->children['expr'];
        if ($expression instanceof Node) {
            if ($expression->kind !== \ast\AST_NAME) {
                return self::STATUS_PROCEED;  // best guess
            }
            $function_name = $expression->children['name'];
            if (!\is_string($function_name)) {
                return self::STATUS_PROCEED;
            }
        } else {
            if (!\is_string($expression)) {
                return self::STATUS_THROW;  // Probably impossible.
            }
            $function_name = $expression;
        }
        if (!$function_name) {
            return self::STATUS_THROW;  // nonsense such as ''();
        }
        if ($function_name[0] === '\\') {
            $function_name = \substr($function_name, 1);
        }
        // @phan-suppress-next-line PhanPossiblyFalseTypeArgumentInternal
        if (\strcasecmp($function_name, 'trigger_error') === 0) {
            return self::computeTriggerErrorStatusCodeForConstant($node->children['args']->children[1] ?? null);
        }
        // TODO: Could allow .phan/config.php or plugins to define additional behaviors, e.g. for methods.
        // E.g. if (!$var) {HttpFramework::generate_302_and_die(); }
        return self::STATUS_PROCEED;
    }

    /**
     * @param Node|string|int|float $constant_ast
     */
    private static function computeTriggerErrorStatusCodeForConstant($constant_ast) : int
    {
        // return PROCEED if this can't be determined.
        // TODO: Could check for integer literals
        if (!($constant_ast instanceof Node)) {
            return self::STATUS_PROCEED;
        }
        if ($constant_ast->kind !== \ast\AST_CONST) {
            return self::STATUS_PROCEED;
        }
        $name = $constant_ast->children['name']->children['name'] ?? null;
        if (!\is_string($name)) {
            return self::STATUS_PROCEED;
        }
        if (\in_array($name, ['E_ERROR', 'E_PARSE', 'E_CORE_ERROR', 'E_COMPILE_ERROR', 'E_USER_ERROR'], true)) {
            return self::STATUS_RETURN;
        }
        if ($name === 'E_RECOVERABLE_ERROR') {
            return self::STATUS_THROW;
        }

        return self::STATUS_PROCEED;  // Assume this is a warning or notice?
    }

    /**
     * A statement list has the weakest return status out of all of the (non-PROCEEDing) statements.
     * FIXME: This is buggy, doesn't account for one statement having STATUS_CONTINUE some of the time but not all of it.
     *       (We don't check for STATUS_CONTINUE yet, so this doesn't matter yet.)
     * @return int the corresponding status code
     */
    public function visitStmtList(Node $node)
    {
        $status = $node->flags & self::STATUS_BITMASK;
        if ($status) {
            return $status;
        }
        $status = $this->computeStatusOfBlock($node->children);
        $node->flags = $status;
        return $status;
    }

    /**
     * Analyzes a node with kind \ast\AST_IF
     * @return int the exit status of a block (whether or not it would unconditionally exit, return, throw, etc.
     * @override
     */
    public function visitIf(Node $node)
    {
        $status = $node->flags & self::STATUS_BITMASK;
        if ($status) {
            return $status;
        }
        $status = $this->computeStatusOfIf($node);
        $node->flags = $status;
        return $status;
    }

    private function computeStatusOfIf(Node $node) : int
    {
        $has_if_elems_for_all_cases = false;
        $combined_statuses = 0;
        foreach ($node->children as $child_node) {
            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable this is never null
            $status = $this->visitStmtList($child_node->children['stmts']);
            $combined_statuses |= $status;

            $cond_node = $child_node->children['cond'];
            // check for "else" or "elseif (true)"
            if ($cond_node === null || self::isTruthyLiteral($cond_node)) {
                $has_if_elems_for_all_cases = true;
                break;
            }
        }
        if (!$has_if_elems_for_all_cases) {
            $combined_statuses |= self::STATUS_PROCEED;
        }
        return $combined_statuses;
    }


    /**
     * Analyzes a node with kind \ast\AST_DO_WHILE
     * @return int the exit status of a block (whether or not it would unconditionally exit, return, throw, etc.
     */
    public function visitDoWhile(Node $node)
    {
        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable this is never null
        $inner_status = $this->visitStmtList($node->children['stmts']);
        if (($inner_status & ~self::STATUS_THROW_OR_RETURN_BITMASK) === 0) {
            // The inner block throws or returns before the end can be reached.
            return $inner_status;
        }
        // TODO: Check for unconditionally false conditions.
        if (self::isTruthyLiteral($node->children['cond'])) {
            // Use a special case to analyze "while (1) {exprs}" or "for (; true; ) {exprs}"
            // TODO: identify infinite loops, mark those as STATUS_NO_PROCEED or STATUS_RETURN.
            return $this->computeDerivedStatusOfInfiniteLoop($inner_status);
        }
        // This is (to our awareness) **not** an infinite loop


        // 1. break/continue apply to the inside of a loop, not outside. Not going to analyze "break 2;", may emit an info level issue in the future.
        // 2. We assume that it's possible that any given loop can have 0 iterations.
        //    A TODO exists above to check for special cases.
        return ($inner_status & ~self::STATUS_CONTINUE_OR_BREAK) | self::STATUS_PROCEED;
    }

    /**
     * Analyzes a node with kind \ast\AST_IF_ELEM
     * @return int the exit status of a block (whether or not it would unconditionally exit, return, throw, etc.
     */
    public function visitIfElem(Node $node)
    {
        $status = $node->flags & self::STATUS_BITMASK;
        if ($status) {
            return $status;
        }
        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable this is never null
        $status = $this->visitStmtList($node->children['stmts']);
        $node->flags = $status;
        return $status;
    }

    /**
     * @return int the corresponding status code
     */
    public function visitGoto(Node $node)
    {
        return self::STATUS_GOTO;
    }

    /**
     * @param array<int,Node> $block
     * @return int the exit status of a block (whether or not it would unconditionally exit, return, throw, etc.
     */
    private function computeStatusOfBlock(array $block) : int
    {
        $maybe_status = 0;
        foreach ($block as $child) {
            if ($child === null) {
                continue;
            }
            // e.g. can be non-Node for statement lists such as `if ($a) { return; }echo "X";2;` (under unknown conditions)
            if (!($child instanceof Node)) {
                continue;
            }
            $status = $this->check($child);
            if (($status & self::STATUS_PROCEED) === 0) {
                // If it's guaranteed we won't stop after this statement,
                // then skip the subsequent statements.
                return $status | ($maybe_status & ~self::STATUS_PROCEED);
            }
            $maybe_status |= $status;
        }
        return self::STATUS_PROCEED | $maybe_status;
    }

    /**
     * Will the node $node unconditionally never fall through to the following statement?
     */
    public static function willUnconditionallySkipRemainingStatements(Node $node) : bool
    {
        return ((new self())->__invoke($node) & self::STATUS_MAYBE_PROCEED) === 0;
    }

    /**
     * Will the node $node unconditionally throw or return (or exit),
     */
    public static function willUnconditionallyThrowOrReturn(Node $node) : bool
    {
        return ((new self())->__invoke($node) & ~self::STATUS_THROW_OR_RETURN_BITMASK) === 0;
    }
}
