<?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;

use function array_fill;
use function sys_get_temp_dir;
use function tempnam;
use function unlink;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Medium;
use SebastianBergmann\CodeCoverage\Data\ProcessedCodeCoverageData;
use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData;
use SebastianBergmann\CodeCoverage\Driver\Driver;
use SebastianBergmann\CodeCoverage\Driver\Granularity;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Test\Target\TargetCollection;
use SebastianBergmann\Environment\Runtime;

#[CoversClass(CodeCoverage::class)]
#[Medium]
final class CodeCoverageTest extends TestCase
{
    private ?CodeCoverage $coverage = null;

    protected function setUp(): void
    {
        $runtime = new Runtime;

        if ($runtime->canCollectCodeCoverage()) {
            $filter = new Filter;

            $this->coverage = new CodeCoverage(
                (new Selector)->select($filter),
                $filter,
            );
        }
    }

    public function testCollect(): void
    {
        $this->requireDriver();

        $coverage = $this->getLineCoverageForBankAccount();

        $this->assertEquals(
            $this->getExpectedLineCoverageDataArrayForBankAccount(),
            $this->lineCoverageKeyedByTestId($coverage->getData()),
        );

        $this->assertEquals(
            [
                'BankAccountTest::testBalanceIsInitiallyZero'       => ['size' => 'unknown', 'status' => 'unknown', 'time' => 0.1],
                'BankAccountTest::testBalanceCannotBecomeNegative'  => ['size' => 'unknown', 'status' => 'unknown', 'time' => 0.2],
                'BankAccountTest::testBalanceCannotBecomeNegative2' => ['size' => 'unknown', 'status' => 'unknown', 'time' => 0.3],
                'BankAccountTest::testDepositWithdrawMoney'         => ['size' => 'unknown', 'status' => 'unknown', 'time' => 0.4],
            ],
            $coverage->getTests(),
        );
    }

    public function testIncludeListFiltering(): void
    {
        $coverage = $this->requireDriver();

        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = RawCodeCoverageData::fromLineCoverage([
            TEST_FILES_PATH . 'BankAccount.php' => [
                29 => -1,
                31 => -1,
            ],
            TEST_FILES_PATH . 'CoverageClassTest.php' => [
                29 => -1,
                31 => -1,
            ],
        ]);

        $coverage->append($data, 'A test', true);

        $this->assertContains(TEST_FILES_PATH . 'BankAccount.php', $coverage->getData()->coveredFiles());
        $this->assertNotContains(TEST_FILES_PATH . 'CoverageClassTest.php', $coverage->getData()->coveredFiles());
    }

    public function testExcludeNonExecutableLines(): void
    {
        $coverage = $this->requireDriver();

        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = RawCodeCoverageData::fromLineCoverage([
            TEST_FILES_PATH . 'BankAccount.php' => array_fill(1, 100, -1),
        ]);

        $coverage->append($data, 'A test', true);

        $expectedLineCoverage = [
            TEST_FILES_PATH . 'BankAccount.php' => [
                8  => [],
                13 => [],
                14 => [],
                16 => [],
                22 => [],
                24 => [],
                29 => [],
                31 => [],
                32 => null,
            ],
        ];

        $this->assertEquals($expectedLineCoverage, $coverage->getData()->lineCoverage());
    }

    public function testMerge(): void
    {
        $this->requireDriver();

        $coverage = $this->getLineCoverageForBankAccountForFirstTwoTests();

        $coverage->merge($this->getLineCoverageForBankAccountForLastTwoTests());

        $this->assertEquals(
            $this->getExpectedLineCoverageDataArrayForBankAccount(),
            $this->lineCoverageKeyedByTestId($coverage->getData()),
        );
    }

    public function testMergeReverseOrder(): void
    {
        $this->requireDriver();

        $coverage = $this->getLineCoverageForBankAccountForLastTwoTests();

        $coverage->merge($this->getLineCoverageForBankAccountForFirstTwoTests());

        $this->assertEquals(
            $this->getExpectedLineCoverageDataArrayForBankAccountInReverseOrder(),
            $this->lineCoverageKeyedByTestId($coverage->getData()),
        );
    }

    public function testMerge2(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $coverage->merge($this->getLineCoverageForBankAccount());

        $this->assertEquals(
            $this->getExpectedLineCoverageDataArrayForBankAccount(),
            $this->lineCoverageKeyedByTestId($coverage->getData()),
        );
    }

    public function testClearCache(): void
    {
        $coverage = $this->requireDriver();

        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = RawCodeCoverageData::fromLineCoverage([
            TEST_FILES_PATH . 'BankAccount.php' => [29 => -1],
        ]);

        $coverage->append($data, 'A test', true);

        $report1 = $coverage->getReport();

        $coverage->clearCache();

        $report2 = $coverage->getReport();

        $this->assertNotSame($report1, $report2);
    }

    public function testSetData(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $data = new ProcessedCodeCoverageData;

        $coverage->setData($data);

        $this->assertSame($data, $coverage->getData(true));
    }

    public function testSetTests(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $tests = [
            'test1' => ['size' => 'small', 'status' => 'success', 'time' => 0.1],
        ];

        $coverage->setTests($tests);

        $this->assertSame($tests, $coverage->getTests());
    }

    public function testEnableAndDisableCheckForUnintentionallyCoveredCode(): void
    {
        $coverage = $this->requireDriver();

        $coverage->enableCheckForUnintentionallyCoveredCode();
        $coverage->disableCheckForUnintentionallyCoveredCode();

        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = RawCodeCoverageData::fromLineCoverage([
            TEST_FILES_PATH . 'BankAccount.php' => [29 => 1, 31 => 1],
        ]);

        $coverage->append($data, 'A test', true);

        $this->assertNotEmpty($coverage->getTests());
    }

    public function testIncludeAndExcludeUncoveredFiles(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $coverage->excludeUncoveredFiles();
        $coverage->includeUncoveredFiles();

        $this->assertNotNull($coverage->getData(true));
    }

    public function testEnableAndDisableAnnotationsForIgnoringCode(): void
    {
        $coverage = $this->requireDriver();

        $coverage->disableAnnotationsForIgnoringCode();
        $coverage->enableAnnotationsForIgnoringCode();

        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = RawCodeCoverageData::fromLineCoverage([
            TEST_FILES_PATH . 'BankAccount.php' => [29 => -1],
        ]);

        $coverage->append($data, 'A test', true);

        $this->assertNotEmpty($coverage->getData()->coveredFiles());
    }

    public function testIgnoreAndDoNotIgnoreDeprecatedCode(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $coverage->ignoreDeprecatedCode();
        $coverage->doNotIgnoreDeprecatedCode();

        $this->assertNotNull($coverage->getData(true));
    }

    public function testCacheStaticAnalysisAndDoNotCacheStaticAnalysis(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $this->assertFalse($coverage->cachesStaticAnalysis());

        $directory = sys_get_temp_dir();

        $this->assertNotEmpty($directory);

        $coverage->cacheStaticAnalysis($directory);

        $this->assertTrue($coverage->cachesStaticAnalysis());

        $coverage->doNotCacheStaticAnalysis();

        $this->assertFalse($coverage->cachesStaticAnalysis());
    }

    public function testCacheDirectoryReturnsCacheDirectory(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $dir = sys_get_temp_dir();

        $this->assertNotEmpty($dir);

        $coverage->cacheStaticAnalysis($dir);

        $this->assertSame($dir, $coverage->cacheDirectory());
    }

    public function testCacheDirectoryThrowsWhenNotConfigured(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $this->expectException(StaticAnalysisCacheNotConfiguredException::class);

        $coverage->cacheDirectory();
    }

    public function testDisableBranchAndPathCoverageAndCollectsBranchAndPathCoverage(): void
    {
        $driver = $this->createStub(Driver::class);
        $driver->method('granularity')->willReturn(Granularity::Line);

        $coverage = new CodeCoverage($driver, new Filter);

        $this->assertFalse($coverage->collectsBranchAndPathCoverage());

        $coverage->disableBranchAndPathCoverage();

        $this->assertFalse($coverage->collectsBranchAndPathCoverage());
    }

    public function testAppendThrowsWhenIdIsNull(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $this->expectException(TestIdMissingException::class);

        $coverage->append(
            RawCodeCoverageData::fromLineCoverage([]),
        );
    }

    public function testAppendReturnsEarlyWhenAppendIsFalse(): void
    {
        $coverage = $this->requireDriver();

        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = RawCodeCoverageData::fromLineCoverage([
            TEST_FILES_PATH . 'BankAccount.php' => [29 => 1],
        ]);

        $coverage->append($data, 'A test', false);

        $this->assertSame([], $coverage->getTests());
    }

    public function testAppendReturnsEarlyWhenLineCoverageIsEmpty(): void
    {
        $coverage = $this->requireDriver();

        $data = RawCodeCoverageData::fromLineCoverage([]);

        $coverage->append($data, 'A test', true);

        $this->assertSame([], $coverage->getTests());
    }

    public function testGetDataIncludesUncoveredFiles(): void
    {
        $coverage = $this->requireDriver();

        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = $coverage->getData();

        $this->assertContains(TEST_FILES_PATH . 'BankAccount.php', $data->coveredFiles());
    }

    public function testGetDataExcludesUncoveredFilesWhenRaw(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = $coverage->getData(true);

        $this->assertSame([], $data->coveredFiles());
    }

    public function testAnalyserUsesCacheWhenConfigured(): void
    {
        $coverage = $this->requireDriver();

        $tmpDir = tempnam(sys_get_temp_dir(), 'phpcc_');
        unlink($tmpDir);

        $coverage->cacheStaticAnalysis($tmpDir);
        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = RawCodeCoverageData::fromLineCoverage([
            TEST_FILES_PATH . 'BankAccount.php' => [29 => -1],
        ]);

        $coverage->append($data, 'A test', true);

        $this->assertTrue($coverage->cachesStaticAnalysis());
        $this->assertSame($tmpDir, $coverage->cacheDirectory());
    }

    public function testDisableAnnotationsForIgnoringCodeSkipsIgnoredLinesFilter(): void
    {
        $coverage = $this->requireDriver();

        $coverage->disableAnnotationsForIgnoringCode();
        $coverage->filter()->includeFile(TEST_FILES_PATH . 'BankAccount.php');

        $data = RawCodeCoverageData::fromLineCoverage([
            TEST_FILES_PATH . 'BankAccount.php' => [29 => -1],
        ]);

        $coverage->append($data, 'A test', true);

        $this->assertContains(TEST_FILES_PATH . 'BankAccount.php', $coverage->getData()->coveredFiles());
    }

    public function testEmptyTargetCollectionIsValidWithoutBuildingCodeUnitMap(): void
    {
        $coverage = new CodeCoverage(
            $this->createStub(Driver::class),
            new Filter,
        );

        $this->assertTrue($coverage->validate(TargetCollection::fromArray([]))->isSuccess());
    }

    public function testFileThatCannotBeParsedHasCoverageBasedOnRawDriverData(): void
    {
        $file = TEST_FILES_PATH . 'source_that_cannot_be_parsed.php';

        $driver = new FakeDriver(
            RawCodeCoverageData::fromLineCoverage([
                $file => [3 => 1, 5 => 1],
            ]),
        );

        $filter = new Filter;
        $filter->includeFile($file);

        $coverage = new CodeCoverage($driver, $filter);

        $coverage->start('A test', null, true);
        $coverage->stop();

        $parseErrors = $coverage->parseErrors();

        $this->assertArrayHasKey($file, $parseErrors);
        $this->assertStringContainsString('Syntax error', $parseErrors[$file]);

        $lineCoverage = $this->lineCoverageKeyedByTestId($coverage->getData());

        $this->assertArrayHasKey($file, $lineCoverage);

        // Line 3 is removed by the line-based @codeCoverageIgnore annotations,
        // which do not require the parser; line 5 keeps the raw driver data
        $this->assertSame([5 => ['A test' => 1]], $lineCoverage[$file]);
    }

    private function requireDriver(): CodeCoverage
    {
        if ($this->coverage === null) {
            $this->markTestSkipped('No code coverage driver available');
        }

        return $this->coverage;
    }
}
