<?php

namespace Psalm\Internal\PhpVisitor;

use PhpParser;
use PhpParser\ErrorHandler\Collecting;

use function count;
use function preg_match_all;
use function preg_replace;
use function reset;
use function str_repeat;
use function strlen;
use function strpos;
use function strrpos;
use function substr;
use function substr_count;
use function substr_replace;
use function token_get_all;

use const PREG_OFFSET_CAPTURE;
use const PREG_SET_ORDER;

/**
 * Given a list of file diffs, this scans an AST to find the sections it can replace, and parses
 * just those methods.
 *
 * @internal
 */
class PartialParserVisitor extends PhpParser\NodeVisitorAbstract
{
    /** @var array<int, array{int, int, int, int, int, string}> */
    private $offset_map;

    /** @var bool */
    private $must_rescan = false;

    /** @var int */
    private $non_method_changes;

    /** @var string */
    private $a_file_contents;

    /** @var string */
    private $b_file_contents;

    /** @var int */
    private $a_file_contents_length;

    /** @var PhpParser\Parser */
    private $parser;

    /** @var PhpParser\ErrorHandler\Collecting */
    private $error_handler;

    /** @param array<int, array{int, int, int, int, int, string}> $offset_map */
    public function __construct(
        PhpParser\Parser $parser,
        PhpParser\ErrorHandler\Collecting $error_handler,
        array $offset_map,
        string $a_file_contents,
        string $b_file_contents
    ) {
        $this->parser = $parser;
        $this->error_handler = $error_handler;
        $this->offset_map = $offset_map;
        $this->a_file_contents = $a_file_contents;
        $this->a_file_contents_length = strlen($a_file_contents);
        $this->b_file_contents = $b_file_contents;
        $this->non_method_changes = count($offset_map);
    }

    /**
     * @return null|int|PhpParser\Node
     */
    public function enterNode(PhpParser\Node $node, bool &$traverseChildren = true)
    {
        /** @var array{startFilePos: int, endFilePos: int, startLine: int} */
        $attrs = $node->getAttributes();

        if ($cs = $node->getComments()) {
            $stmt_start_pos = $cs[0]->getStartFilePos();
        } else {
            $stmt_start_pos = $attrs['startFilePos'];
        }

        $stmt_end_pos = $attrs['endFilePos'];

        $start_offset = 0;
        $end_offset = 0;

        $line_offset = 0;

        foreach ($this->offset_map as [$a_s, $a_e, $b_s, $b_e, $line_diff]) {
            if ($a_s > $stmt_end_pos) {
                break;
            }

            $end_offset = $b_e - $a_e;

            if ($a_s < $stmt_start_pos) {
                $start_offset = $b_s - $a_s;
            }

            if ($a_e < $stmt_start_pos) {
                $start_offset = $end_offset;

                $line_offset = $line_diff;

                continue;
            }

            if ($node instanceof PhpParser\Node\Stmt\ClassMethod
                || $node instanceof PhpParser\Node\Stmt\Namespace_
                || $node instanceof PhpParser\Node\Stmt\ClassLike
            ) {
                if ($node instanceof PhpParser\Node\Stmt\ClassMethod) {
                    if ($a_s >= $stmt_start_pos && $a_e <= $stmt_end_pos) {
                        foreach ($this->offset_map as [$a_s2, $a_e2, $b_s2, $b_e2]) {
                            if ($a_s2 > $stmt_end_pos) {
                                break;
                            }

                            // we have a diff that goes outside the bounds that we care about
                            if ($a_e2 > $stmt_end_pos) {
                                $this->must_rescan = true;

                                return PhpParser\NodeTraverser::STOP_TRAVERSAL;
                            }

                            $end_offset = $b_e2 - $a_e2;

                            if ($a_s2 < $stmt_start_pos) {
                                $start_offset = $b_s2 - $a_s2;
                            }

                            if ($a_e2 < $stmt_start_pos) {
                                $start_offset = $end_offset;

                                $line_offset = $line_diff;

                                continue;
                            }

                            if ($a_s2 >= $stmt_start_pos && $a_e2 <= $stmt_end_pos) {
                                --$this->non_method_changes;
                            }
                        }

                        $stmt_start_pos += $start_offset;
                        $stmt_end_pos += $end_offset;

                        $current_line = substr_count(substr($this->b_file_contents, 0, $stmt_start_pos), "\n");

                        $method_contents = substr(
                            $this->b_file_contents,
                            $stmt_start_pos,
                            $stmt_end_pos - $stmt_start_pos + 1
                        );

                        if (!$method_contents) {
                            $this->must_rescan = true;

                            return PhpParser\NodeTraverser::STOP_TRAVERSAL;
                        }

                        $error_handler = new Collecting();

                        $fake_class = '<?php class _ {' . $method_contents . '}';

                        $extra_characters = [];

                        // To avoid a parser error during completion we replace
                        //
                        // Foo::
                        // if (...) {}
                        //
                        // with
                        //
                        // Foo::;
                        // if (...) {}
                        //
                        // When we insert the extra semicolon we have to keep track of the places
                        // we inserted it, and then shift the AST node offsets accordingly after parsing
                        // is complete.
                        //
                        // If anyone's unlucky enough to have a static method named "if" with a newline
                        // before the method name e.g.
                        //
                        // Foo::
                        // if(...);
                        //
                        // This transformation will break that.
                        preg_match_all(
                            '/(->|::)(\n\s*(if|list)\s*\()/',
                            $fake_class,
                            $matches,
                            PREG_OFFSET_CAPTURE | PREG_SET_ORDER
                        );

                        foreach ($matches as $match) {
                            $fake_class = substr_replace(
                                $fake_class,
                                $match[1][0] . ';' . $match[2][0],
                                $match[0][1],
                                strlen($match[0][0])
                            );

                            $extra_characters[] = $match[2][1];
                        }

                        $replacement_stmts = $this->parser->parse(
                            $fake_class,
                            $error_handler
                        ) ?: [];

                        if (!$replacement_stmts
                            || !$replacement_stmts[0] instanceof PhpParser\Node\Stmt\ClassLike
                            || count($replacement_stmts[0]->stmts) !== 1
                        ) {
                            $hacky_class_fix = self::balanceBrackets($fake_class);

                            if ($replacement_stmts
                                && $replacement_stmts[0] instanceof PhpParser\Node\Stmt\ClassLike
                                && count($replacement_stmts[0]->stmts) !== 1
                            ) {
                                $this->must_rescan = true;

                                return PhpParser\NodeTraverser::STOP_TRAVERSAL;
                            }

                            // changes "): {" to ") {"
                            $hacky_class_fix = preg_replace('/(\)[\s]*):([\s]*\{)/', '$1 $2', $hacky_class_fix);

                            if ($hacky_class_fix !== $fake_class) {
                                $replacement_stmts = $this->parser->parse(
                                    $hacky_class_fix,
                                    $error_handler
                                ) ?: [];
                            }

                            if (!$replacement_stmts
                                || !$replacement_stmts[0] instanceof PhpParser\Node\Stmt\ClassLike
                                || count($replacement_stmts[0]->stmts) > 1
                            ) {
                                $this->must_rescan = true;

                                return PhpParser\NodeTraverser::STOP_TRAVERSAL;
                            }
                        }

                        $replacement_stmts = $replacement_stmts[0]->stmts;

                        $extra_offsets = [];

                        foreach ($extra_characters as $extra_offset) {
                            $l = strlen($fake_class);

                            for ($i = $extra_offset; $i < $l; $i++) {
                                if (isset($extra_offsets[$i])) {
                                    $extra_offsets[$i]--;
                                } else {
                                    $extra_offsets[$i] = -1;
                                }
                            }
                        }

                        $renumbering_traverser = new PhpParser\NodeTraverser;
                        $position_shifter = new OffsetShifterVisitor(
                            $stmt_start_pos - 15,
                            $current_line,
                            $extra_offsets
                        );
                        $renumbering_traverser->addVisitor($position_shifter);
                        $replacement_stmts = $renumbering_traverser->traverse($replacement_stmts);

                        if ($error_handler->hasErrors()) {
                            foreach ($error_handler->getErrors() as $error) {
                                if ($error->hasColumnInfo()) {
                                    /** @var array{startFilePos: int, endFilePos: int} */
                                    $error_attrs = $error->getAttributes();
                                    $error = new PhpParser\Error(
                                        $error->getRawMessage(),
                                        [
                                            'startFilePos' => $stmt_start_pos + $error_attrs['startFilePos'] - 15,
                                            'endFilePos' => $stmt_start_pos + $error_attrs['endFilePos'] - 15,
                                            'startLine' => $error->getStartLine() + $current_line + $line_offset,
                                        ]
                                    );
                                }

                                $this->error_handler->handleError($error);
                            }
                        }

                        $error_handler->clearErrors();

                        $traverseChildren = false;

                        return reset($replacement_stmts);
                    }

                    $this->must_rescan = true;

                    return PhpParser\NodeTraverser::STOP_TRAVERSAL;
                }

                if ($node->stmts) {
                    /** @var int */
                    $stmt_inner_start_pos = $node->stmts[0]->getAttribute('startFilePos');

                    /** @var int */
                    $stmt_inner_end_pos = $node->stmts[count($node->stmts) - 1]->getAttribute('endFilePos');

                    if ($node instanceof PhpParser\Node\Stmt\ClassLike) {
                        /** @psalm-suppress PossiblyFalseOperand */
                        $stmt_inner_start_pos = strrpos(
                            $this->a_file_contents,
                            '{',
                            $stmt_inner_start_pos - $this->a_file_contents_length
                        ) + 1;

                        if ($stmt_inner_end_pos < $this->a_file_contents_length) {
                            $stmt_inner_end_pos = strpos($this->a_file_contents, '}', $stmt_inner_end_pos + 1);
                        }
                    }

                    if ($a_s > $stmt_inner_start_pos && $a_e < $stmt_inner_end_pos) {
                        continue;
                    }
                }
            }

            $this->must_rescan = true;

            return PhpParser\NodeTraverser::STOP_TRAVERSAL;
        }

        if ($start_offset !== 0 || $end_offset !== 0 || $line_offset !== 0) {
            if ($start_offset !== 0) {
                if ($cs) {
                    $new_comments = [];

                    foreach ($cs as $c) {
                        if ($c instanceof PhpParser\Comment\Doc) {
                            $new_comments[] = new PhpParser\Comment\Doc(
                                $c->getText(),
                                $c->getStartLine() + $line_offset,
                                $c->getStartFilePos() + $start_offset
                            );
                        } else {
                            $new_comments[] = new PhpParser\Comment(
                                $c->getText(),
                                $c->getStartLine() + $line_offset,
                                $c->getStartFilePos() + $start_offset
                            );
                        }
                    }

                    $node->setAttribute('comments', $new_comments);

                    $node->setAttribute('startFilePos', $attrs['startFilePos'] + $start_offset);
                } else {
                    $node->setAttribute('startFilePos', $stmt_start_pos + $start_offset);
                }
            }

            if ($end_offset !== 0) {
                $node->setAttribute('endFilePos', $stmt_end_pos + $end_offset);
            }

            if ($line_offset !== 0) {
                $node->setAttribute('startLine', $attrs['startLine'] + $line_offset);
            }

            return $node;
        }

        return null;
    }

    public function mustRescan(): bool
    {
        return $this->must_rescan || $this->non_method_changes;
    }

    /**
     * @psalm-pure
     */
    private static function balanceBrackets(string $fake_class): string
    {
        $tokens = token_get_all($fake_class);

        $brace_count = 0;

        foreach ($tokens as $token) {
            if ($token === '{') {
                ++$brace_count;
            } elseif ($token === '}') {
                --$brace_count;
            }
        }

        if ($brace_count > 0) {
            $fake_class .= str_repeat('}', $brace_count);
        }

        return $fake_class;
    }
}
