<?php
namespace Psalm\Internal\Analyzer\Statements\Block;

use PhpParser;
use Psalm\Internal\Analyzer\ScopeAnalyzer;
use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer;
use Psalm\Internal\Analyzer\StatementsAnalyzer;
use Psalm\Internal\Clause;
use Psalm\CodeLocation;
use Psalm\Config;
use Psalm\Context;
use Psalm\IssueBuffer;
use Psalm\Internal\Scope\LoopScope;
use Psalm\Type;
use Psalm\Type\Algebra;
use Psalm\Type\Reconciler;
use function array_merge;
use function array_keys;
use function array_unique;
use function array_intersect_key;
use function in_array;

/**
 * @internal
 */
class LoopAnalyzer
{
    /**
     * Checks an array of statements in a loop
     *
     * @param  array<PhpParser\Node\Stmt>   $stmts
     * @param  PhpParser\Node\Expr[]        $pre_conditions
     * @param  PhpParser\Node\Expr[]        $post_expressions
     * @param  Context                      loop_scope->loop_context
     * @param  Context                      $loop_scope->loop_parent_context
     * @param  bool                         $is_do
     *
     * @return false|null
     */
    public static function analyze(
        StatementsAnalyzer $statements_analyzer,
        array $stmts,
        array $pre_conditions,
        array $post_expressions,
        LoopScope $loop_scope,
        Context &$inner_context = null,
        $is_do = false
    ) {
        $traverser = new PhpParser\NodeTraverser;

        $assignment_mapper = new \Psalm\Internal\Visitor\AssignmentMapVisitor($loop_scope->loop_context->self);
        $traverser->addVisitor($assignment_mapper);

        $traverser->traverse(array_merge($stmts, $post_expressions));

        $assignment_map = $assignment_mapper->getAssignmentMap();

        $assignment_depth = 0;

        $asserted_var_ids = [];

        $pre_condition_clauses = [];

        $original_protected_var_ids = $loop_scope->loop_parent_context->protected_var_ids;

        $codebase = $statements_analyzer->getCodebase();

        if ($pre_conditions) {
            foreach ($pre_conditions as $pre_condition) {
                $pre_condition_clauses = array_merge(
                    $pre_condition_clauses,
                    Algebra::getFormula(
                        \spl_object_id($pre_condition),
                        $pre_condition,
                        $loop_scope->loop_context->self,
                        $statements_analyzer,
                        $codebase
                    )
                );
            }
        } else {
            $asserted_var_ids = Context::getNewOrUpdatedVarIds(
                $loop_scope->loop_parent_context,
                $loop_scope->loop_context
            );
        }

        $final_actions = ScopeAnalyzer::getFinalControlActions(
            $stmts,
            $statements_analyzer->node_data,
            Config::getInstance()->exit_functions,
            $loop_scope->loop_context->break_types
        );

        $does_always_break = $final_actions === [ScopeAnalyzer::ACTION_BREAK];

        if ($assignment_map) {
            $first_var_id = array_keys($assignment_map)[0];

            $assignment_depth = self::getAssignmentMapDepth($first_var_id, $assignment_map);
        }

        $loop_scope->loop_context->parent_context = $loop_scope->loop_parent_context;

        $pre_outer_context = $loop_scope->loop_parent_context;

        if ($assignment_depth === 0 || $does_always_break) {
            $inner_context = clone $loop_scope->loop_context;

            foreach ($inner_context->vars_in_scope as $context_var_id => $context_type) {
                $inner_context->vars_in_scope[$context_var_id] = clone $context_type;
            }

            $inner_context->loop_scope = $loop_scope;

            $inner_context->parent_context = $loop_scope->loop_context;
            $old_referenced_var_ids = $inner_context->referenced_var_ids;
            $inner_context->referenced_var_ids = [];

            foreach ($pre_conditions as $pre_condition) {
                self::applyPreConditionToLoopContext(
                    $statements_analyzer,
                    $pre_condition,
                    $pre_condition_clauses,
                    $inner_context,
                    $loop_scope->loop_parent_context,
                    $is_do
                );
            }

            $inner_context->protected_var_ids = $loop_scope->protected_var_ids;

            $statements_analyzer->analyze($stmts, $inner_context);
            self::updateLoopScopeContexts($loop_scope, $loop_scope->loop_parent_context);

            foreach ($post_expressions as $post_expression) {
                if (ExpressionAnalyzer::analyze(
                    $statements_analyzer,
                    $post_expression,
                    $loop_scope->loop_context
                ) === false
                ) {
                    return false;
                }
            }

            $new_referenced_var_ids = $inner_context->referenced_var_ids;
            $inner_context->referenced_var_ids = $old_referenced_var_ids + $inner_context->referenced_var_ids;

            $loop_scope->loop_parent_context->vars_possibly_in_scope = array_merge(
                $inner_context->vars_possibly_in_scope,
                $loop_scope->loop_parent_context->vars_possibly_in_scope
            );
        } else {
            $pre_outer_context = clone $loop_scope->loop_parent_context;

            $analyzer = $statements_analyzer->getCodebase()->analyzer;

            $original_mixed_counts = $analyzer->getMixedCountsForFile($statements_analyzer->getFilePath());

            IssueBuffer::startRecording();

            foreach ($pre_conditions as $pre_condition) {
                $asserted_var_ids = array_merge(
                    self::applyPreConditionToLoopContext(
                        $statements_analyzer,
                        $pre_condition,
                        $pre_condition_clauses,
                        $loop_scope->loop_context,
                        $loop_scope->loop_parent_context,
                        $is_do
                    ),
                    $asserted_var_ids
                );
            }

            // record all the vars that existed before we did the first pass through the loop
            $pre_loop_context = clone $loop_scope->loop_context;

            $inner_context = clone $loop_scope->loop_context;

            foreach ($inner_context->vars_in_scope as $context_var_id => $context_type) {
                $inner_context->vars_in_scope[$context_var_id] = clone $context_type;
            }

            $inner_context->parent_context = $loop_scope->loop_context;
            $inner_context->loop_scope = $loop_scope;

            $old_referenced_var_ids = $inner_context->referenced_var_ids;
            $inner_context->referenced_var_ids = [];

            $asserted_var_ids = array_unique($asserted_var_ids);

            $inner_context->protected_var_ids = $loop_scope->protected_var_ids;

            $statements_analyzer->analyze($stmts, $inner_context);

            self::updateLoopScopeContexts($loop_scope, $pre_outer_context);

            $inner_context->protected_var_ids = $original_protected_var_ids;

            foreach ($post_expressions as $post_expression) {
                if (ExpressionAnalyzer::analyze($statements_analyzer, $post_expression, $inner_context) === false) {
                    return false;
                }
            }

            /**
             * @var array<string, bool>
             */
            $new_referenced_var_ids = $inner_context->referenced_var_ids;
            $inner_context->referenced_var_ids = array_intersect_key(
                $old_referenced_var_ids,
                $inner_context->referenced_var_ids
            );

            $recorded_issues = IssueBuffer::clearRecordingLevel();
            IssueBuffer::stopRecording();

            for ($i = 0; $i < $assignment_depth; ++$i) {
                $vars_to_remove = [];

                $loop_scope->iteration_count++;

                $has_changes = false;

                // reset the $inner_context to what it was before we started the analysis,
                // but union the types with what's in the loop scope

                foreach ($inner_context->vars_in_scope as $var_id => $type) {
                    if (in_array($var_id, $asserted_var_ids, true)) {
                        // set the vars to whatever the while/foreach loop expects them to be
                        if (!isset($pre_loop_context->vars_in_scope[$var_id])
                            || !$type->equals($pre_loop_context->vars_in_scope[$var_id])
                        ) {
                            $has_changes = true;
                        }
                    } elseif (isset($pre_outer_context->vars_in_scope[$var_id])) {
                        if (!$type->equals($pre_outer_context->vars_in_scope[$var_id])) {
                            $has_changes = true;

                            // widen the foreach context type with the initial context type
                            $inner_context->vars_in_scope[$var_id] = Type::combineUnionTypes(
                                $inner_context->vars_in_scope[$var_id],
                                $pre_outer_context->vars_in_scope[$var_id]
                            );

                            // if there's a change, invalidate related clauses
                            $pre_loop_context->removeVarFromConflictingClauses($var_id);
                        }

                        if (isset($loop_scope->loop_context->vars_in_scope[$var_id])
                            && !$type->equals($loop_scope->loop_context->vars_in_scope[$var_id])
                        ) {
                            $has_changes = true;

                            // widen the foreach context type with the initial context type
                            $inner_context->vars_in_scope[$var_id] = Type::combineUnionTypes(
                                $inner_context->vars_in_scope[$var_id],
                                $loop_scope->loop_context->vars_in_scope[$var_id]
                            );

                            // if there's a change, invalidate related clauses
                            $pre_loop_context->removeVarFromConflictingClauses($var_id);
                        }
                    } else {
                        // give an opportunity to redeemed UndefinedVariable issues
                        if ($recorded_issues) {
                            $has_changes = true;
                        }

                        // if we're in a do block we don't want to remove vars before evaluating
                        // the where conditional
                        if (!$is_do) {
                            $vars_to_remove[] = $var_id;
                        }
                    }
                }

                if ($inner_context->collect_references) {
                    foreach ($inner_context->unreferenced_vars as $var_id => $locations) {
                        if (!isset($pre_outer_context->vars_in_scope[$var_id])) {
                            $loop_scope->unreferenced_vars[$var_id] = $locations;
                            unset($inner_context->unreferenced_vars[$var_id]);
                        }
                    }
                }

                $loop_scope->loop_parent_context->vars_possibly_in_scope = array_merge(
                    $inner_context->vars_possibly_in_scope,
                    $loop_scope->loop_parent_context->vars_possibly_in_scope
                );

                // if there are no changes to the types, no need to re-examine
                if (!$has_changes) {
                    break;
                }

                if ($inner_context->collect_references) {
                    foreach ($loop_scope->possibly_unreferenced_vars as $var_id => $locations) {
                        if (isset($inner_context->unreferenced_vars[$var_id])) {
                            $inner_context->unreferenced_vars[$var_id] += $locations;
                        } else {
                            $inner_context->unreferenced_vars[$var_id] = $locations;
                        }
                    }
                }

                // remove vars that were defined in the foreach
                foreach ($vars_to_remove as $var_id) {
                    unset($inner_context->vars_in_scope[$var_id]);
                }

                $inner_context->clauses = $pre_loop_context->clauses;

                $analyzer->setMixedCountsForFile($statements_analyzer->getFilePath(), $original_mixed_counts);
                IssueBuffer::startRecording();

                foreach ($pre_conditions as $pre_condition) {
                    self::applyPreConditionToLoopContext(
                        $statements_analyzer,
                        $pre_condition,
                        $pre_condition_clauses,
                        $inner_context,
                        $loop_scope->loop_parent_context,
                        false
                    );
                }

                foreach ($asserted_var_ids as $var_id) {
                    if (!isset($inner_context->vars_in_scope[$var_id])
                        || $inner_context->vars_in_scope[$var_id]->getId()
                            !== $pre_loop_context->vars_in_scope[$var_id]->getId()
                        || $inner_context->vars_in_scope[$var_id]->from_docblock
                            !== $pre_loop_context->vars_in_scope[$var_id]->from_docblock
                    ) {
                        $inner_context->vars_in_scope[$var_id] = clone $pre_loop_context->vars_in_scope[$var_id];
                    }
                }

                $inner_context->clauses = $pre_loop_context->clauses;

                $inner_context->protected_var_ids = $loop_scope->protected_var_ids;

                $traverser = new PhpParser\NodeTraverser;

                $traverser->addVisitor(
                    new \Psalm\Internal\Visitor\NodeCleanerVisitor(
                        $statements_analyzer->node_data
                    )
                );
                $traverser->traverse($stmts);

                $statements_analyzer->analyze($stmts, $inner_context);

                self::updateLoopScopeContexts($loop_scope, $pre_outer_context);

                $inner_context->protected_var_ids = $original_protected_var_ids;

                foreach ($post_expressions as $post_expression) {
                    if (ExpressionAnalyzer::analyze($statements_analyzer, $post_expression, $inner_context) === false) {
                        return false;
                    }
                }

                $recorded_issues = IssueBuffer::clearRecordingLevel();

                IssueBuffer::stopRecording();
            }

            if ($recorded_issues) {
                foreach ($recorded_issues as $recorded_issue) {
                    // if we're not in any loops then this will just result in the issue being emitted
                    IssueBuffer::bubbleUp($recorded_issue);
                }
            }
        }

        $does_sometimes_break = in_array(ScopeAnalyzer::ACTION_BREAK, $loop_scope->final_actions, true);
        $does_always_break = $loop_scope->final_actions === [ScopeAnalyzer::ACTION_BREAK];

        if ($does_sometimes_break) {
            if ($loop_scope->possibly_redefined_loop_parent_vars !== null) {
                foreach ($loop_scope->possibly_redefined_loop_parent_vars as $var => $type) {
                    $loop_scope->loop_parent_context->vars_in_scope[$var] = Type::combineUnionTypes(
                        $type,
                        $loop_scope->loop_parent_context->vars_in_scope[$var]
                    );
                }
            }
        }

        foreach ($loop_scope->loop_parent_context->vars_in_scope as $var_id => $type) {
            if (!isset($loop_scope->loop_context->vars_in_scope[$var_id])) {
                continue;
            }

            if ($loop_scope->loop_context->vars_in_scope[$var_id]->getId() !== $type->getId()) {
                $loop_scope->loop_parent_context->vars_in_scope[$var_id] = Type::combineUnionTypes(
                    $loop_scope->loop_parent_context->vars_in_scope[$var_id],
                    $loop_scope->loop_context->vars_in_scope[$var_id]
                );

                $loop_scope->loop_parent_context->removeVarFromConflictingClauses($var_id);
            }
        }

        if (!$does_always_break) {
            foreach ($loop_scope->loop_parent_context->vars_in_scope as $var_id => $type) {
                if (!isset($inner_context->vars_in_scope[$var_id])) {
                    unset($loop_scope->loop_parent_context->vars_in_scope[$var_id]);
                    continue;
                }

                if ($inner_context->vars_in_scope[$var_id]->hasMixed()) {
                    $loop_scope->loop_parent_context->vars_in_scope[$var_id] =
                        $inner_context->vars_in_scope[$var_id];
                    $loop_scope->loop_parent_context->removeVarFromConflictingClauses($var_id);
                    continue;
                }

                if ($inner_context->vars_in_scope[$var_id]->getId() !== $type->getId()) {
                    $loop_scope->loop_parent_context->vars_in_scope[$var_id] = Type::combineUnionTypes(
                        $loop_scope->loop_parent_context->vars_in_scope[$var_id],
                        $inner_context->vars_in_scope[$var_id]
                    );

                    $loop_scope->loop_parent_context->removeVarFromConflictingClauses($var_id);
                }
            }
        }

        if ($pre_conditions && $pre_condition_clauses && !ScopeAnalyzer::doesEverBreak($stmts)) {
            // if the loop contains an assertion and there are no break statements, we can negate that assertion
            // and apply it to the current context
            $negated_pre_condition_types = Algebra::getTruthsFromFormula(
                Algebra::negateFormula($pre_condition_clauses)
            );

            if ($negated_pre_condition_types) {
                $changed_var_ids = [];

                $vars_in_scope_reconciled = Reconciler::reconcileKeyedTypes(
                    $negated_pre_condition_types,
                    [],
                    $inner_context->vars_in_scope,
                    $changed_var_ids,
                    [],
                    $statements_analyzer,
                    [],
                    true,
                    new CodeLocation($statements_analyzer->getSource(), $pre_conditions[0])
                );

                foreach ($changed_var_ids as $var_id => $_) {
                    if (isset($vars_in_scope_reconciled[$var_id])
                        && isset($loop_scope->loop_parent_context->vars_in_scope[$var_id])
                    ) {
                        $loop_scope->loop_parent_context->vars_in_scope[$var_id] = $vars_in_scope_reconciled[$var_id];
                    }

                    $loop_scope->loop_parent_context->removeVarFromConflictingClauses($var_id);
                }
            }
        }

        $loop_scope->loop_context->referenced_var_ids = array_merge(
            array_intersect_key(
                $inner_context->referenced_var_ids,
                $pre_outer_context->vars_in_scope
            ),
            $loop_scope->loop_context->referenced_var_ids
        );

        if ($inner_context->collect_references) {
            foreach ($loop_scope->possibly_unreferenced_vars as $var_id => $locations) {
                if (isset($inner_context->unreferenced_vars[$var_id])) {
                    $inner_context->unreferenced_vars[$var_id] += $locations;
                }
            }

            foreach ($inner_context->unreferenced_vars as $var_id => $locations) {
                if (!isset($new_referenced_var_ids[$var_id])
                    || !isset($pre_outer_context->vars_in_scope[$var_id])
                    || $does_always_break
                ) {
                    if (!isset($loop_scope->loop_context->unreferenced_vars[$var_id])) {
                        $loop_scope->loop_context->unreferenced_vars[$var_id] = $locations;
                    } else {
                        $loop_scope->loop_context->unreferenced_vars[$var_id] += $locations;
                    }
                } else {
                    $statements_analyzer->registerVariableUses($locations);
                }
            }

            foreach ($loop_scope->unreferenced_vars as $var_id => $locations) {
                if (isset($loop_scope->referenced_var_ids[$var_id])) {
                    $statements_analyzer->registerVariableUses($locations);
                } else {
                    if (!isset($loop_scope->loop_context->unreferenced_vars[$var_id])) {
                        $loop_scope->loop_context->unreferenced_vars[$var_id] = $locations;
                    } else {
                        $loop_scope->loop_context->unreferenced_vars[$var_id] += $locations;
                    }
                }
            }
        }
    }

    /**
     * @param  LoopScope $loop_scope
     * @param  Context   $pre_outer_context
     *
     * @return void
     */
    private static function updateLoopScopeContexts(
        LoopScope $loop_scope,
        Context $pre_outer_context
    ) {
        $updated_loop_vars = [];

        if (!in_array(ScopeAnalyzer::ACTION_CONTINUE, $loop_scope->final_actions, true)) {
            $loop_scope->loop_context->vars_in_scope = $pre_outer_context->vars_in_scope;
        } else {
            if ($loop_scope->redefined_loop_vars !== null) {
                foreach ($loop_scope->redefined_loop_vars as $var => $type) {
                    $loop_scope->loop_context->vars_in_scope[$var] = $type;
                    $updated_loop_vars[$var] = true;
                }
            }

            if ($loop_scope->possibly_redefined_loop_vars) {
                foreach ($loop_scope->possibly_redefined_loop_vars as $var => $type) {
                    if ($loop_scope->loop_context->hasVariable($var)
                        && !isset($updated_loop_vars[$var])
                    ) {
                        $loop_scope->loop_context->vars_in_scope[$var] = Type::combineUnionTypes(
                            $loop_scope->loop_context->vars_in_scope[$var],
                            $type
                        );
                    }
                }
            }
        }

        // merge vars possibly in scope at the end of each loop
        $loop_scope->loop_context->vars_possibly_in_scope = array_merge(
            $loop_scope->loop_context->vars_possibly_in_scope,
            $loop_scope->vars_possibly_in_scope
        );
    }

    /**
     * @param  PhpParser\Node\Expr $pre_condition
     * @param  array<int, Clause>  $pre_condition_clauses
     * @param  Context             $loop_context
     * @param  Context             $outer_context
     *
     * @return string[]
     */
    private static function applyPreConditionToLoopContext(
        StatementsAnalyzer $statements_analyzer,
        PhpParser\Node\Expr $pre_condition,
        array $pre_condition_clauses,
        Context $loop_context,
        Context $outer_context,
        bool $is_do
    ) {
        $pre_referenced_var_ids = $loop_context->referenced_var_ids;
        $loop_context->referenced_var_ids = [];

        $loop_context->inside_conditional = true;

        $suppressed_issues = $statements_analyzer->getSuppressedIssues();

        if ($is_do) {
            if (!in_array('RedundantCondition', $suppressed_issues, true)) {
                $statements_analyzer->addSuppressedIssues(['RedundantCondition']);
            }
            if (!in_array('RedundantConditionGivenDocblockType', $suppressed_issues, true)) {
                $statements_analyzer->addSuppressedIssues(['RedundantConditionGivenDocblockType']);
            }
            if (!in_array('TypeDoesNotContainType', $suppressed_issues, true)) {
                $statements_analyzer->addSuppressedIssues(['TypeDoesNotContainType']);
            }
        }

        if (ExpressionAnalyzer::analyze($statements_analyzer, $pre_condition, $loop_context) === false) {
            return [];
        }

        $loop_context->inside_conditional = false;

        $new_referenced_var_ids = $loop_context->referenced_var_ids;
        $loop_context->referenced_var_ids = array_merge($pre_referenced_var_ids, $new_referenced_var_ids);

        $asserted_var_ids = Context::getNewOrUpdatedVarIds($outer_context, $loop_context);

        $loop_context->clauses = Algebra::simplifyCNF(
            array_merge($outer_context->clauses, $pre_condition_clauses)
        );

        $active_while_types = [];

        $reconcilable_while_types = Algebra::getTruthsFromFormula(
            $loop_context->clauses,
            \spl_object_id($pre_condition),
            $new_referenced_var_ids
        );

        $changed_var_ids = [];

        if ($reconcilable_while_types) {
            $pre_condition_vars_in_scope_reconciled = Reconciler::reconcileKeyedTypes(
                $reconcilable_while_types,
                $active_while_types,
                $loop_context->vars_in_scope,
                $changed_var_ids,
                $new_referenced_var_ids,
                $statements_analyzer,
                [],
                true,
                new CodeLocation($statements_analyzer->getSource(), $pre_condition)
            );

            $loop_context->vars_in_scope = $pre_condition_vars_in_scope_reconciled;
        }

        if ($is_do) {
            if (!in_array('RedundantCondition', $suppressed_issues, true)) {
                $statements_analyzer->removeSuppressedIssues(['RedundantCondition']);
            }
            if (!in_array('RedundantConditionGivenDocblockType', $suppressed_issues, true)) {
                $statements_analyzer->removeSuppressedIssues(['RedundantConditionGivenDocblockType']);
            }
            if (!in_array('TypeDoesNotContainType', $suppressed_issues, true)) {
                $statements_analyzer->removeSuppressedIssues(['TypeDoesNotContainType']);
            }
        }

        if ($is_do) {
            return [];
        }

        foreach ($asserted_var_ids as $var_id) {
            $loop_context->clauses = Context::filterClauses(
                $var_id,
                $loop_context->clauses,
                null,
                $statements_analyzer
            );
        }

        return $asserted_var_ids;
    }

    /**
     * @param  string                               $first_var_id
     * @param  array<string, array<string, bool>>   $assignment_map
     *
     * @return int
     */
    private static function getAssignmentMapDepth($first_var_id, array $assignment_map)
    {
        $max_depth = 0;

        $assignment_var_ids = $assignment_map[$first_var_id];
        unset($assignment_map[$first_var_id]);

        foreach ($assignment_var_ids as $assignment_var_id => $_) {
            $depth = 1;

            if (isset($assignment_map[$assignment_var_id])) {
                $depth = 1 + self::getAssignmentMapDepth($assignment_var_id, $assignment_map);
            }

            if ($depth > $max_depth) {
                $max_depth = $depth;
            }
        }

        return $max_depth;
    }
}
