<?php declare(strict_types=1);
/*
 * This file is part of phpunit/php-code-coverage.
 *
 * (c) Sebastian Bergmann <sebastian@phpunit.de>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace SebastianBergmann\CodeCoverage\StaticAnalysis;

use function assert;
use function explode;
use function file_get_contents;
use function preg_match;
use function str_contains;
use PhpParser\NodeTraverser;
use PhpParser\ParserFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RequiresPhp;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\Attributes\Ticket;
use PHPUnit\Framework\TestCase;

#[CoversClass(ExecutableLinesFindingVisitor::class)]
#[Small]
#[Group('static-analysis')]
final class ExecutableLinesFindingVisitorTest extends TestCase
{
    public function testExecutableLinesAreGroupedByBranch(): void
    {
        $this->doTestSelfDescribingAssert(TEST_FILES_PATH . 'source_for_branched_exec_lines.php');
    }

    #[RequiresPhp('>=8.1.0')]
    public function testExecutableLinesAreGroupedByBranchPhp81(): void
    {
        $this->doTestSelfDescribingAssert(TEST_FILES_PATH . 'source_for_branched_exec_lines_php81.php');
    }

    #[RequiresPhp('>=8.2.0')]
    public function testExecutableLinesAreGroupedByBranchPhp82(): void
    {
        $this->doTestSelfDescribingAssert(TEST_FILES_PATH . 'source_for_branched_exec_lines_php82.php');
    }

    public function testArrowFunctionIsProcessedCorrectly(): void
    {
        $source = file_get_contents(TEST_FILES_PATH . 'source_with_arrow_function.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);

        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $result = $executableLinesFindingVisitor->executableLinesGroupedByBranch();

        // The multi-line arrow function body should be an executable line
        $this->assertArrayHasKey(5, $result);
    }

    public function testEmptyForLoopIsProcessedCorrectly(): void
    {
        $source = file_get_contents(TEST_FILES_PATH . 'source_with_empty_for_loops.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);

        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $result = $executableLinesFindingVisitor->executableLinesGroupedByBranch();

        // for(;;) has no init/cond/loop, so no branch is set for the for statement
        // for(;; ++$i) has only loop part
        $this->assertNotEmpty($result);
    }

    #[Ticket('https://github.com/sebastianbergmann/phpunit/issues/6442')]
    public function testAbstractMethodDeclarationsAreNotExecutable(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_with_abstract_method.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines = $executableLinesFindingVisitor->executableLinesGroupedByBranch();

        $this->assertArrayNotHasKey(6, $executableLines);
        $this->assertArrayNotHasKey(8, $executableLines);

        $this->assertArrayHasKey(12, $executableLines);
    }

    #[Ticket('https://github.com/sebastianbergmann/php-code-coverage/issues/967')]
    public function testMatchArmsAreProcessedCorrectly(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_match_expression.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $this->assertSame(
            [
                8  => 2,
                9  => 3,
                10 => 4,
                11 => 5,
                12 => 6,
                13 => 7,
            ],
            $executableLinesFindingVisitor->executableLinesGroupedByBranch(),
        );
    }

    #[RequiresPhp('>=8.4.0')]
    public function testPropertyHooksAreProcessedCorrectly(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_with_property_hooks.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines     = $executableLinesFindingVisitor->executableLinesGroupedByBranch();
        $branchOperatorLines = $executableLinesFindingVisitor->branchOperatorLines();

        $this->assertArrayNotHasKey(4, $executableLines);

        $this->assertArrayNotHasKey(9, $executableLines);
        $this->assertArrayNotHasKey(9, $branchOperatorLines);

        $this->assertArrayNotHasKey(10, $executableLines);
        $this->assertArrayHasKey(11, $executableLines);
        $this->assertArrayNotHasKey(12, $executableLines);

        $this->assertArrayHasKey(17, $executableLines);
    }

    #[RequiresPhp('>=8.4.0')]
    #[Ticket('https://github.com/sebastianbergmann/php-code-coverage/issues/1230')]
    public function testShortFormPropertyHookBodyIsNotExecutable(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_with_property_hook_short_form.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines     = $executableLinesFindingVisitor->executableLinesGroupedByBranch();
        $branchOperatorLines = $executableLinesFindingVisitor->branchOperatorLines();

        $this->assertArrayNotHasKey(7, $executableLines);
        $this->assertArrayNotHasKey(7, $branchOperatorLines);

        $this->assertArrayNotHasKey(11, $executableLines);
        $this->assertArrayNotHasKey(11, $branchOperatorLines);
        $this->assertArrayNotHasKey(12, $executableLines);
        $this->assertArrayNotHasKey(12, $branchOperatorLines);
    }

    public function testEmptyMatchExpressionIsProcessedCorrectly(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_with_empty_match.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines = $executableLinesFindingVisitor->executableLinesGroupedByBranch();

        $this->assertArrayHasKey(4, $executableLines);
    }

    public function testBranchOperatorLinesAccessorReturnsLinesAddedByBranchHandlers(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_match_true_expression.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $this->assertSame(
            [
                9  => true,
                10 => true,
                11 => true,
                12 => true,
            ],
            $executableLinesFindingVisitor->branchOperatorLines(),
        );
    }

    public function testCaseStatementsAreExecutableButNotBranchOperators(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_with_switch_case.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines     = $executableLinesFindingVisitor->executableLinesGroupedByBranch();
        $branchOperatorLines = $executableLinesFindingVisitor->branchOperatorLines();

        $this->assertArrayHasKey(9, $executableLines);
        $this->assertArrayHasKey(11, $executableLines);

        $this->assertArrayNotHasKey(9, $branchOperatorLines);
        $this->assertArrayNotHasKey(11, $branchOperatorLines);
    }

    #[Ticket('https://github.com/sebastianbergmann/php-code-coverage/issues/1156')]
    public function testStatementsConsistingOfASideEffectFreeExpressionAreNotExecutable(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_dead_scalar_literal_statement.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines     = $executableLinesFindingVisitor->executableLinesGroupedByBranch();
        $branchOperatorLines = $executableLinesFindingVisitor->branchOperatorLines();

        foreach ([8, 9, 10, 11, 12, 13] as $deadLine) {
            $this->assertArrayNotHasKey($deadLine, $executableLines);
            $this->assertArrayNotHasKey($deadLine, $branchOperatorLines);
        }

        $this->assertArrayHasKey(15, $executableLines);
    }

    #[Ticket('https://github.com/sebastianbergmann/php-code-coverage/issues/1154')]
    public function testMatchTrueDoesNotMarkOpenerAndCloserAsExecutable(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_match_true_expression.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines = $executableLinesFindingVisitor->executableLinesGroupedByBranch();

        $this->assertArrayNotHasKey(8, $executableLines);
        $this->assertArrayNotHasKey(13, $executableLines);
        $this->assertArrayHasKey(9, $executableLines);
        $this->assertArrayHasKey(10, $executableLines);
        $this->assertArrayHasKey(11, $executableLines);
        $this->assertArrayHasKey(12, $executableLines);
    }

    #[Ticket('https://github.com/sebastianbergmann/php-code-coverage/issues/1160')]
    public function testTernaryInsideSpreadDoesNotCreateSeparateBranches(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_ternary_in_spread.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines     = $executableLinesFindingVisitor->executableLinesGroupedByBranch();
        $branchOperatorLines = $executableLinesFindingVisitor->branchOperatorLines();

        foreach ([5, 6, 7, 8, 9, 10] as $line) {
            $this->assertArrayHasKey($line, $executableLines);
        }

        $branch = $executableLines[5];

        foreach ([6, 7, 8, 9, 10] as $line) {
            $this->assertArrayHasKey($line, $executableLines);
            $this->assertSame($branch, $executableLines[$line]);
        }

        foreach ([7, 8, 9] as $line) {
            $this->assertArrayNotHasKey($line, $branchOperatorLines);
        }
    }

    #[Ticket('https://github.com/sebastianbergmann/php-code-coverage/issues/1159')]
    public function testStatementsInClosureNestedInCallArgumentAreNotMarkedAsBranchOperators(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_callable_inside_call_argument.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines     = $executableLinesFindingVisitor->executableLinesGroupedByBranch();
        $branchOperatorLines = $executableLinesFindingVisitor->branchOperatorLines();

        $this->assertArrayHasKey(8, $executableLines);
        $this->assertArrayHasKey(10, $executableLines);

        $this->assertArrayNotHasKey(8, $branchOperatorLines);
        $this->assertArrayNotHasKey(10, $branchOperatorLines);

        $this->assertArrayHasKey(11, $branchOperatorLines);
    }

    #[Ticket('https://github.com/sebastianbergmann/php-code-coverage/issues/1305')]
    public function testTernaryInsideArrayLiteralDoesNotCreateSeparateBranches(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_ternary_in_array_literal.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines     = $executableLinesFindingVisitor->executableLinesGroupedByBranch();
        $branchOperatorLines = $executableLinesFindingVisitor->branchOperatorLines();

        $this->assertArrayHasKey(5, $executableLines);

        $branch = $executableLines[5];

        foreach ([6, 7, 8, 9, 10, 11, 12, 13, 14] as $line) {
            $this->assertArrayHasKey($line, $executableLines);
            $this->assertSame($branch, $executableLines[$line]);
        }

        // The engine folds constant operands of a ternary or coalesce
        // operator into the opcode that builds the array literal, so no
        // driver can report these lines on their own
        foreach ([7, 8, 10, 11, 13] as $line) {
            $this->assertArrayNotHasKey($line, $branchOperatorLines);
        }
    }

    #[RequiresPhp('>=8.4.0')]
    #[Ticket('https://github.com/sebastianbergmann/php-code-coverage/issues/1305')]
    public function testLongFormPropertyHookBodyIsNotMarkedAsBranchOperator(): void
    {
        $source = file_get_contents(__DIR__ . '/../../../_files/source_with_ternary_in_property_hook.php');
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLines     = $executableLinesFindingVisitor->executableLinesGroupedByBranch();
        $branchOperatorLines = $executableLinesFindingVisitor->branchOperatorLines();

        foreach ([9, 10, 11] as $line) {
            $this->assertArrayHasKey($line, $executableLines);

            // PCOV 1.0.12 and earlier report no line of a property hook body
            $this->assertArrayNotHasKey($line, $branchOperatorLines);
        }
    }

    private function doTestSelfDescribingAssert(string $filename): void
    {
        $source = file_get_contents($filename);
        assert($source !== false);
        $parser = (new ParserFactory)->createForHostVersion();
        $nodes  = $parser->parse($source);
        assert($nodes !== null);
        $executableLinesFindingVisitor = new ExecutableLinesFindingVisitor($source);

        $traverser = new NodeTraverser;
        $traverser->addVisitor($executableLinesFindingVisitor);
        $traverser->traverse($nodes);

        $executableLinesGroupedByBranch = $executableLinesFindingVisitor->executableLinesGroupedByBranch();

        $linesFromSource = explode("\n", $source);
        $expected        = [];

        $branch = 0;

        foreach ($linesFromSource as $lineNumber => $line) {
            if (str_contains($line, 'LINE_ADDED_IN_TEST')) {
                $expected[1 + $lineNumber] = $branch;

                continue;
            }

            if (1 !== preg_match('#^\s*[^/].+// (?<branchIncrement>[+-]?\d+)$#', $line, $matches)) {
                continue;
            }

            $branch += (int) ($matches['branchIncrement']);
            $expected[1 + $lineNumber] = $branch;
        }

        $this->assertEquals(
            $expected,
            $executableLinesGroupedByBranch,
        );
    }
}
