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

use function array_diff_key;

use function array_flip;

use function array_keys;

use function array_merge;

use function array_unique;

use function array_values;

use function count;

use function explode;

use function get_class;

use function is_array;

use function is_file;

use function sort;

use PHPUnit\Framework\TestCase;

use PHPUnit\Runner\PhptTestCase;

use PHPUnit\Util\Test;

use ReflectionClass;

use SebastianBergmann\CodeCoverage\Driver\Driver;

use SebastianBergmann\CodeCoverage\Node\Builder;

use SebastianBergmann\CodeCoverage\Node\Directory;

use SebastianBergmann\CodeCoverage\StaticAnalysis\CachingFileAnalyser;

use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;

use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingFileAnalyser;

use SebastianBergmann\CodeUnitReverseLookup\Wizard;



/**

 * Provides collection functionality for PHP code coverage information.

 */

final class CodeCoverage

{

    private const UNCOVERED_FILES = 'UNCOVERED_FILES';



    /**

     * @var Driver

     */

    private $driver;



    /**

     * @var Filter

     */

    private $filter;



    /**

     * @var Wizard

     */

    private $wizard;



    /**

     * @var bool

     */

    private $checkForUnintentionallyCoveredCode = false;



    /**

     * @var bool

     */

    private $includeUncoveredFiles = true;



    /**

     * @var bool

     */

    private $processUncoveredFiles = false;



    /**

     * @var bool

     */

    private $ignoreDeprecatedCode = false;



    /**

     * @var PhptTestCase|string|TestCase

     */

    private $currentId;



    /**

     * Code coverage data.

     *

     * @var ProcessedCodeCoverageData

     */

    private $data;



    /**

     * @var bool

     */

    private $useAnnotationsForIgnoringCode = true;



    /**

     * Test data.

     *

     * @var array

     */

    private $tests = [];



    /**

     * @psalm-var list<class-string>

     */

    private $parentClassesExcludedFromUnintentionallyCoveredCodeCheck = [];



    /**

     * @var ?FileAnalyser

     */

    private $analyser;



    /**

     * @var ?string

     */

    private $cacheDirectory;



    public function __construct(Driver $driver, Filter $filter)

    {

        $this->driver = $driver;

        $this->filter = $filter;

        $this->data   = new ProcessedCodeCoverageData;

        $this->wizard = new Wizard;

    }



    /**

     * Returns the code coverage information as a graph of node objects.

     */

    public function getReport(): Directory

    {

        return (new Builder($this->analyser()))->build($this);

    }



    /**

     * Clears collected code coverage data.

     */

    public function clear(): void

    {

        $this->currentId = null;

        $this->data      = new ProcessedCodeCoverageData;

        $this->tests     = [];

    }



    /**

     * Returns the filter object used.

     */

    public function filter(): Filter

    {

        return $this->filter;

    }



    /**

     * Returns the collected code coverage data.

     */

    public function getData(bool $raw = false): ProcessedCodeCoverageData

    {

        if (!$raw) {

            if ($this->processUncoveredFiles) {

                $this->processUncoveredFilesFromFilter();

            } elseif ($this->includeUncoveredFiles) {

                $this->addUncoveredFilesFromFilter();

            }

        }



        return $this->data;

    }



    /**

     * Sets the coverage data.

     */

    public function setData(ProcessedCodeCoverageData $data): void

    {

        $this->data = $data;

    }



    /**

     * Returns the test data.

     */

    public function getTests(): array

    {

        return $this->tests;

    }



    /**

     * Sets the test data.

     */

    public function setTests(array $tests): void

    {

        $this->tests = $tests;

    }



    /**

     * Start collection of code coverage information.

     *

     * @param PhptTestCase|string|TestCase $id

     */

    public function start($id, bool $clear = false): void

    {

        if ($clear) {

            $this->clear();

        }



        $this->currentId = $id;



        $this->driver->start();

    }



    /**

     * Stop collection of code coverage information.

     *

     * @param array|false $linesToBeCovered

     */

    public function stop(bool $append = true, $linesToBeCovered = [], array $linesToBeUsed = []): RawCodeCoverageData

    {

        if (!is_array($linesToBeCovered) && $linesToBeCovered !== false) {

            throw new InvalidArgumentException(

                '$linesToBeCovered must be an array or false'

            );

        }



        $data = $this->driver->stop();

        $this->append($data, null, $append, $linesToBeCovered, $linesToBeUsed);



        $this->currentId = null;



        return $data;

    }



    /**

     * Appends code coverage data.

     *

     * @param PhptTestCase|string|TestCase $id

     * @param array|false                  $linesToBeCovered

     *

     * @throws ReflectionException

     * @throws TestIdMissingException

     * @throws UnintentionallyCoveredCodeException

     */

    public function append(RawCodeCoverageData $rawData, $id = null, bool $append = true, $linesToBeCovered = [], array $linesToBeUsed = []): void

    {

        if ($id === null) {

            $id = $this->currentId;

        }



        if ($id === null) {

            throw new TestIdMissingException;

        }



        $this->applyFilter($rawData);



        $this->applyExecutableLinesFilter($rawData);



        if ($this->useAnnotationsForIgnoringCode) {

            $this->applyIgnoredLinesFilter($rawData);

        }



        $this->data->initializeUnseenData($rawData);



        if (!$append) {

            return;

        }



        if ($id !== self::UNCOVERED_FILES) {

            $this->applyCoversAnnotationFilter(

                $rawData,

                $linesToBeCovered,

                $linesToBeUsed

            );



            if (empty($rawData->lineCoverage())) {

                return;

            }



            $size         = 'unknown';

            $status       = -1;

            $fromTestcase = false;



            if ($id instanceof TestCase) {

                $fromTestcase = true;

                $_size        = $id->getSize();



                if ($_size === Test::SMALL) {

                    $size = 'small';

                } elseif ($_size === Test::MEDIUM) {

                    $size = 'medium';

                } elseif ($_size === Test::LARGE) {

                    $size = 'large';

                }



                $status = $id->getStatus();

                $id     = get_class($id) . '::' . $id->getName();

            } elseif ($id instanceof PhptTestCase) {

                $fromTestcase = true;

                $size         = 'large';

                $id           = $id->getName();

            }



            $this->tests[$id] = ['size' => $size, 'status' => $status, 'fromTestcase' => $fromTestcase];



            $this->data->markCodeAsExecutedByTestCase($id, $rawData);

        }

    }



    /**

     * Merges the data from another instance.

     */

    public function merge(self $that): void

    {

        $this->filter->includeFiles(

            $that->filter()->files()

        );



        $this->data->merge($that->data);



        $this->tests = array_merge($this->tests, $that->getTests());

    }



    public function enableCheckForUnintentionallyCoveredCode(): void

    {

        $this->checkForUnintentionallyCoveredCode = true;

    }



    public function disableCheckForUnintentionallyCoveredCode(): void

    {

        $this->checkForUnintentionallyCoveredCode = false;

    }



    public function includeUncoveredFiles(): void

    {

        $this->includeUncoveredFiles = true;

    }



    public function excludeUncoveredFiles(): void

    {

        $this->includeUncoveredFiles = false;

    }



    public function processUncoveredFiles(): void

    {

        $this->processUncoveredFiles = true;

    }



    public function doNotProcessUncoveredFiles(): void

    {

        $this->processUncoveredFiles = false;

    }



    public function enableAnnotationsForIgnoringCode(): void

    {

        $this->useAnnotationsForIgnoringCode = true;

    }



    public function disableAnnotationsForIgnoringCode(): void

    {

        $this->useAnnotationsForIgnoringCode = false;

    }



    public function ignoreDeprecatedCode(): void

    {

        $this->ignoreDeprecatedCode = true;

    }



    public function doNotIgnoreDeprecatedCode(): void

    {

        $this->ignoreDeprecatedCode = false;

    }



    /**

     * @psalm-assert-if-true !null $this->cacheDirectory

     */

    public function cachesStaticAnalysis(): bool

    {

        return $this->cacheDirectory !== null;

    }



    public function cacheStaticAnalysis(string $directory): void

    {

        $this->cacheDirectory = $directory;

    }



    public function doNotCacheStaticAnalysis(): void

    {

        $this->cacheDirectory = null;

    }



    /**

     * @throws StaticAnalysisCacheNotConfiguredException

     */

    public function cacheDirectory(): string

    {

        if (!$this->cachesStaticAnalysis()) {

            throw new StaticAnalysisCacheNotConfiguredException(

                'The static analysis cache is not configured'

            );

        }



        return $this->cacheDirectory;

    }



    /**

     * @psalm-param class-string $className

     */

    public function excludeSubclassesOfThisClassFromUnintentionallyCoveredCodeCheck(string $className): void

    {

        $this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck[] = $className;

    }



    public function enableBranchAndPathCoverage(): void

    {

        $this->driver->enableBranchAndPathCoverage();

    }



    public function disableBranchAndPathCoverage(): void

    {

        $this->driver->disableBranchAndPathCoverage();

    }



    public function collectsBranchAndPathCoverage(): bool

    {

        return $this->driver->collectsBranchAndPathCoverage();

    }



    public function detectsDeadCode(): bool

    {

        return $this->driver->detectsDeadCode();

    }



    /**

     * Applies the @covers annotation filtering.

     *

     * @param array|false $linesToBeCovered

     *

     * @throws ReflectionException

     * @throws UnintentionallyCoveredCodeException

     */

    private function applyCoversAnnotationFilter(RawCodeCoverageData $rawData, $linesToBeCovered, array $linesToBeUsed): void

    {

        if ($linesToBeCovered === false) {

            $rawData->clear();



            return;

        }



        if (empty($linesToBeCovered)) {

            return;

        }



        if ($this->checkForUnintentionallyCoveredCode &&

            (!$this->currentId instanceof TestCase ||

            (!$this->currentId->isMedium() && !$this->currentId->isLarge()))) {

            $this->performUnintentionallyCoveredCodeCheck($rawData, $linesToBeCovered, $linesToBeUsed);

        }



        $rawLineData         = $rawData->lineCoverage();

        $filesWithNoCoverage = array_diff_key($rawLineData, $linesToBeCovered);



        foreach (array_keys($filesWithNoCoverage) as $fileWithNoCoverage) {

            $rawData->removeCoverageDataForFile($fileWithNoCoverage);

        }



        if (is_array($linesToBeCovered)) {

            foreach ($linesToBeCovered as $fileToBeCovered => $includedLines) {

                $rawData->keepLineCoverageDataOnlyForLines($fileToBeCovered, $includedLines);

                $rawData->keepFunctionCoverageDataOnlyForLines($fileToBeCovered, $includedLines);

            }

        }

    }



    private function applyFilter(RawCodeCoverageData $data): void

    {

        if ($this->filter->isEmpty()) {

            return;

        }



        foreach (array_keys($data->lineCoverage()) as $filename) {

            if ($this->filter->isExcluded($filename)) {

                $data->removeCoverageDataForFile($filename);

            }

        }

    }



    private function applyExecutableLinesFilter(RawCodeCoverageData $data): void

    {

        foreach (array_keys($data->lineCoverage()) as $filename) {

            if (!$this->filter->isFile($filename)) {

                continue;

            }



            $data->keepLineCoverageDataOnlyForLines(

                $filename,

                $this->analyser()->executableLinesIn($filename)

            );

        }

    }



    private function applyIgnoredLinesFilter(RawCodeCoverageData $data): void

    {

        foreach (array_keys($data->lineCoverage()) as $filename) {

            if (!$this->filter->isFile($filename)) {

                continue;

            }



            $data->removeCoverageDataForLines(

                $filename,

                $this->analyser()->ignoredLinesFor($filename)

            );

        }

    }



    /**

     * @throws UnintentionallyCoveredCodeException

     */

    private function addUncoveredFilesFromFilter(): void

    {

        $uncoveredFiles = array_diff(

            $this->filter->files(),

            $this->data->coveredFiles()

        );



        foreach ($uncoveredFiles as $uncoveredFile) {

            if (is_file($uncoveredFile)) {

                $this->append(

                    RawCodeCoverageData::fromUncoveredFile(

                        $uncoveredFile,

                        $this->analyser()

                    ),

                    self::UNCOVERED_FILES

                );

            }

        }

    }



    /**

     * @throws UnintentionallyCoveredCodeException

     */

    private function processUncoveredFilesFromFilter(): void

    {

        $uncoveredFiles = array_diff(

            $this->filter->files(),

            $this->data->coveredFiles()

        );



        $this->driver->start();



        foreach ($uncoveredFiles as $uncoveredFile) {

            if (is_file($uncoveredFile)) {

                include_once $uncoveredFile;

            }

        }



        $this->append($this->driver->stop(), self::UNCOVERED_FILES);

    }



    /**

     * @throws ReflectionException

     * @throws UnintentionallyCoveredCodeException

     */

    private function performUnintentionallyCoveredCodeCheck(RawCodeCoverageData $data, array $linesToBeCovered, array $linesToBeUsed): void

    {

        $allowedLines = $this->getAllowedLines(

            $linesToBeCovered,

            $linesToBeUsed

        );



        $unintentionallyCoveredUnits = [];



        foreach ($data->lineCoverage() as $file => $_data) {

            foreach ($_data as $line => $flag) {

                if ($flag === 1 && !isset($allowedLines[$file][$line])) {

                    $unintentionallyCoveredUnits[] = $this->wizard->lookup($file, $line);

                }

            }

        }



        $unintentionallyCoveredUnits = $this->processUnintentionallyCoveredUnits($unintentionallyCoveredUnits);



        if (!empty($unintentionallyCoveredUnits)) {

            throw new UnintentionallyCoveredCodeException(

                $unintentionallyCoveredUnits

            );

        }

    }



    private function getAllowedLines(array $linesToBeCovered, array $linesToBeUsed): array

    {

        $allowedLines = [];



        foreach (array_keys($linesToBeCovered) as $file) {

            if (!isset($allowedLines[$file])) {

                $allowedLines[$file] = [];

            }



            $allowedLines[$file] = array_merge(

                $allowedLines[$file],

                $linesToBeCovered[$file]

            );

        }



        foreach (array_keys($linesToBeUsed) as $file) {

            if (!isset($allowedLines[$file])) {

                $allowedLines[$file] = [];

            }



            $allowedLines[$file] = array_merge(

                $allowedLines[$file],

                $linesToBeUsed[$file]

            );

        }



        foreach (array_keys($allowedLines) as $file) {

            $allowedLines[$file] = array_flip(

                array_unique($allowedLines[$file])

            );

        }



        return $allowedLines;

    }



    /**

     * @throws ReflectionException

     */

    private function processUnintentionallyCoveredUnits(array $unintentionallyCoveredUnits): array

    {

        $unintentionallyCoveredUnits = array_unique($unintentionallyCoveredUnits);

        sort($unintentionallyCoveredUnits);



        foreach (array_keys($unintentionallyCoveredUnits) as $k => $v) {

            $unit = explode('::', $unintentionallyCoveredUnits[$k]);



            if (count($unit) !== 2) {

                continue;

            }



            try {

                $class = new ReflectionClass($unit[0]);



                foreach ($this->parentClassesExcludedFromUnintentionallyCoveredCodeCheck as $parentClass) {

                    if ($class->isSubclassOf($parentClass)) {

                        unset($unintentionallyCoveredUnits[$k]);



                        break;

                    }

                }

            } catch (\ReflectionException $e) {

                throw new ReflectionException(

                    $e->getMessage(),

                    (int) $e->getCode(),

                    $e

                );

            }

        }



        return array_values($unintentionallyCoveredUnits);

    }



    private function analyser(): FileAnalyser

    {

        if ($this->analyser !== null) {

            return $this->analyser;

        }



        $this->analyser = new ParsingFileAnalyser(

            $this->useAnnotationsForIgnoringCode,

            $this->ignoreDeprecatedCode

        );



        if ($this->cachesStaticAnalysis()) {

            $this->analyser = new CachingFileAnalyser(

                $this->cacheDirectory,

                $this->analyser

            );

        }



        return $this->analyser;

    }

}

