<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Bridge\PhpUnit\DeprecationErrorHandler;

use Doctrine\Deprecations\Deprecation as DoctrineDeprecation;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Metadata\Api\Groups;
use PHPUnit\Util\Test;
use Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerFor;
use Symfony\Component\ErrorHandler\DebugClassLoader;

class_exists(Groups::class);

/**
 * @internal
 */
class Deprecation
{
    public const PATH_TYPE_VENDOR = 'path_type_vendor';
    public const PATH_TYPE_SELF = 'path_type_internal';
    public const PATH_TYPE_UNDETERMINED = 'path_type_undetermined';

    public const TYPE_SELF = 'type_self';
    public const TYPE_DIRECT = 'type_direct';
    public const TYPE_INDIRECT = 'type_indirect';
    public const TYPE_UNDETERMINED = 'type_undetermined';

    private $trace = [];
    private $message;
    private $languageDeprecation;
    private $originClass;
    private $originMethod;
    private $triggeringFile;
    private $triggeringClass;

    /** @var string[] Absolute paths to vendor directories */
    private static $vendors;

    /**
     * @var string[] Absolute paths to source or tests of the project, cache
     *               directories excluded because it is based on autoloading
     *               rules and cache systems typically do not use those
     */
    private static $internalPaths = [];

    private $originalFilesStack;

    public function __construct(string $message, array $trace, string $file, bool $languageDeprecation = false)
    {
        if (DebugClassLoader::class === ($trace[2]['class'] ?? '')) {
            $this->triggeringClass = $trace[2]['args'][0];
        }

        switch ($trace[2]['function'] ?? '') {
            case 'trigger_deprecation':
                $file = $trace[2]['file'];
                array_splice($trace, 1, 1);
                break;

            case 'delegateTriggerToBackend':
                if (DoctrineDeprecation::class === ($trace[2]['class'] ?? '')) {
                    $file = $trace[3]['file'];
                    array_splice($trace, 1, 2);
                }
                break;
        }

        $this->trace = $trace;
        $this->message = $message;
        $this->languageDeprecation = $languageDeprecation;

        $i = \count($trace);
        while (1 < $i && $this->lineShouldBeSkipped($trace[--$i])) {
            // No-op
        }

        $line = $trace[$i];
        $this->triggeringFile = $file;

        for ($j = 1; $j < $i; ++$j) {
            if (!isset($trace[$j]['function'], $trace[1 + $j]['class'], $trace[1 + $j]['args'][0])) {
                continue;
            }

            if ('trigger_error' === $trace[$j]['function'] && !isset($trace[$j]['class'])) {
                if (DebugClassLoader::class === $trace[1 + $j]['class']) {
                    $class = $trace[1 + $j]['args'][0];
                    $this->triggeringFile = isset($trace[1 + $j]['args'][1]) ? realpath($trace[1 + $j]['args'][1]) : (new \ReflectionClass($class))->getFileName();
                    $this->getOriginalFilesStack();
                    array_splice($this->originalFilesStack, 0, $j, [$this->triggeringFile]);

                    if (preg_match('/(?|"([^"]++)" that is deprecated|should implement method "(?:static )?([^:]++))/', $message, $m) || (!str_contains($message, '()" will return') && !str_contains($message, 'native return type declaration') && preg_match('/^(?:The|Method) "([^":]++)/', $message, $m))) {
                        $this->triggeringFile = (new \ReflectionClass($m[1]))->getFileName();
                        array_unshift($this->originalFilesStack, $this->triggeringFile);
                    }
                }

                break;
            }
        }

        if (!isset($line['object']) && !isset($line['class'])) {
            return;
        }

        set_error_handler(static function () {});
        try {
            $parsedMsg = unserialize($this->message);
        } finally {
            restore_error_handler();
        }
        if ($parsedMsg && isset($parsedMsg['deprecation'])) {
            $this->message = $parsedMsg['deprecation'];
            $this->originClass = $parsedMsg['class'];
            $this->originMethod = $parsedMsg['method'];
            if (isset($parsedMsg['files_stack'])) {
                $this->originalFilesStack = $parsedMsg['files_stack'];
            }
            // If the deprecation has been triggered via
            // \Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerTrait::endTest()
            // then we need to use the serialized information to determine
            // if the error has been triggered from vendor code.
            if (isset($parsedMsg['triggering_file'])) {
                $this->triggeringFile = $parsedMsg['triggering_file'];
            }

            return;
        }

        if (!isset($line['class'], $trace[$i - 2]['function']) || !str_starts_with($line['class'], SymfonyTestsListenerFor::class)) {
            $this->originClass = isset($line['object']) ? \get_class($line['object']) : $line['class'];
            $this->originMethod = $line['function'];

            return;
        }

        $test = $line['args'][0] ?? null;

        if (($test instanceof TestCase || $test instanceof TestSuite) && ('trigger_error' !== $trace[$i - 2]['function'] || isset($trace[$i - 2]['class']))) {
            $this->originClass = $test::class;
            $this->originMethod = $test->getName();
        }
    }

    private function lineShouldBeSkipped(array $line): bool
    {
        if (!isset($line['class'])) {
            return true;
        }
        $class = $line['class'];

        return 'ReflectionMethod' === $class || str_starts_with($class, 'PHPUnit\\');
    }

    public function originatesFromDebugClassLoader(): bool
    {
        return isset($this->triggeringClass);
    }

    public function triggeringClass(): string
    {
        if (null === $this->triggeringClass) {
            throw new \LogicException('Check with originatesFromDebugClassLoader() before calling this method.');
        }

        return $this->triggeringClass;
    }

    public function originatesFromAnObject(): bool
    {
        return isset($this->originClass);
    }

    public function originatingClass(): string
    {
        if (null === $this->originClass) {
            throw new \LogicException('Check with originatesFromAnObject() before calling this method.');
        }

        $class = $this->originClass;

        return str_contains($class, "@anonymous\0") ? (get_parent_class($class) ?: key(class_implements($class)) ?: 'class').'@anonymous' : $class;
    }

    public function originatingMethod(): string
    {
        if (null === $this->originMethod) {
            throw new \LogicException('Check with originatesFromAnObject() before calling this method.');
        }

        return $this->originMethod;
    }

    public function getMessage(): string
    {
        return $this->message;
    }

    public function isLegacy(): bool
    {
        if (!$this->originClass || (new \ReflectionClass($this->originClass))->isInternal()) {
            return false;
        }

        $method = $this->originatingMethod();
        $groups = class_exists(Groups::class, false) ? [new Groups(), 'groups'] : [Test::class, 'getGroups'];

        return str_starts_with($method, 'testLegacy')
            || str_starts_with($method, 'provideLegacy')
            || str_starts_with($method, 'getLegacy')
            || strpos($this->originClass, '\Legacy')
            || \in_array('legacy', $groups($this->originClass, $method), true);
    }

    public function isMuted(): bool
    {
        if ('Function ReflectionType::__toString() is deprecated' !== $this->message) {
            return false;
        }
        if (isset($this->trace[1]['class'])) {
            return str_starts_with($this->trace[1]['class'], 'PHPUnit\\');
        }

        return str_contains($this->triggeringFile, \DIRECTORY_SEPARATOR.'vendor'.\DIRECTORY_SEPARATOR.'phpunit'.\DIRECTORY_SEPARATOR);
    }

    /**
     * Tells whether both the calling package and the called package are vendor
     * packages.
     */
    public function getType(): string
    {
        $pathType = $this->getPathType($this->triggeringFile);
        if ($this->languageDeprecation && self::PATH_TYPE_VENDOR === $pathType) {
            // the triggering file must be used for language deprecations
            return self::TYPE_INDIRECT;
        }
        if (self::PATH_TYPE_SELF === $pathType) {
            return self::TYPE_SELF;
        }
        if (self::PATH_TYPE_UNDETERMINED === $pathType) {
            return self::TYPE_UNDETERMINED;
        }
        $erroringFile = $erroringPackage = null;

        foreach ($this->getOriginalFilesStack() as $file) {
            if ('-' === $file || 'Standard input code' === $file || !realpath($file)) {
                continue;
            }
            if (self::PATH_TYPE_SELF === $pathType = $this->getPathType($file)) {
                return self::TYPE_DIRECT;
            }
            if (self::PATH_TYPE_UNDETERMINED === $pathType) {
                return self::TYPE_UNDETERMINED;
            }
            if (null !== $erroringFile && null !== $erroringPackage) {
                $package = $this->getPackage($file);
                if ('composer' !== $package && $package !== $erroringPackage) {
                    return self::TYPE_INDIRECT;
                }
                continue;
            }
            $erroringFile = $file;
            $erroringPackage = $this->getPackage($file);
        }

        return self::TYPE_DIRECT;
    }

    private function getOriginalFilesStack()
    {
        if (null === $this->originalFilesStack) {
            $this->originalFilesStack = [];
            foreach ($this->trace as $frame) {
                if (!isset($frame['file'], $frame['function']) || (!isset($frame['class']) && \in_array($frame['function'], ['require', 'require_once', 'include', 'include_once'], true))) {
                    continue;
                }

                $this->originalFilesStack[] = $frame['file'];
            }
        }

        return $this->originalFilesStack;
    }

    /**
     * getPathType() should always be called prior to calling this method.
     */
    private function getPackage(string $path): string
    {
        $path = realpath($path) ?: $path;
        foreach (self::getVendors() as $vendorRoot) {
            if (str_starts_with($path, $vendorRoot)) {
                $relativePath = substr($path, \strlen($vendorRoot) + 1);
                $vendor = strstr($relativePath, \DIRECTORY_SEPARATOR, true);
                if (false === $vendor) {
                    return 'symfony';
                }

                return rtrim($vendor.'/'.strstr(substr($relativePath, \strlen($vendor) + 1), \DIRECTORY_SEPARATOR, true), '/');
            }
        }

        throw new \RuntimeException(\sprintf('No vendors found for path "%s".', $path));
    }

    /**
     * @return string[]
     */
    private static function getVendors(): array
    {
        if (null === self::$vendors) {
            self::$vendors = $paths = [];
            self::$vendors[] = \dirname(__DIR__).\DIRECTORY_SEPARATOR.'Legacy';
            if (class_exists(DebugClassLoader::class, false)) {
                self::$vendors[] = \dirname((new \ReflectionClass(DebugClassLoader::class))->getFileName());
            }
            foreach (get_declared_classes() as $class) {
                if ('C' === $class[0] && str_starts_with($class, 'ComposerAutoloaderInit')) {
                    $r = new \ReflectionClass($class);
                    $v = \dirname($r->getFileName(), 2);
                    if (file_exists($v.'/composer/installed.json')) {
                        self::$vendors[] = $v;
                        $loader = require $v.'/autoload.php';
                        $paths = self::addSourcePathsFromPrefixes(
                            array_merge($loader->getPrefixes(), $loader->getPrefixesPsr4()),
                            $paths
                        );
                        $paths = self::addSourcePathsFromPrefixes(
                            ['fallback' => $loader->getFallbackDirs(), 'fallback_psr4' => $loader->getFallbackDirsPsr4()],
                            $paths
                        );
                    }
                }
            }
            foreach ($paths as $path) {
                foreach (self::$vendors as $vendor) {
                    if (!str_starts_with($path, $vendor)) {
                        self::$internalPaths[] = $path;
                    }
                }
            }
        }

        return self::$vendors;
    }

    private static function addSourcePathsFromPrefixes(array $prefixesByNamespace, array $paths): array
    {
        foreach ($prefixesByNamespace as $prefixes) {
            foreach ($prefixes as $prefix) {
                if (false !== realpath($prefix)) {
                    $paths[] = realpath($prefix);
                }
            }
        }

        return $paths;
    }

    private function getPathType(string $path): string
    {
        $realPath = realpath($path);
        if (false === $realPath && '-' !== $path && 'Standard input code' !== $path) {
            return self::PATH_TYPE_UNDETERMINED;
        }
        foreach (self::getVendors() as $vendor) {
            if (str_starts_with($realPath, $vendor) && false !== strpbrk(substr($realPath, \strlen($vendor), 1), '/'.\DIRECTORY_SEPARATOR)) {
                return self::PATH_TYPE_VENDOR;
            }
        }

        foreach (self::$internalPaths as $internalPath) {
            if (str_starts_with($realPath, $internalPath)) {
                return self::PATH_TYPE_SELF;
            }
        }

        return self::PATH_TYPE_UNDETERMINED;
    }

    public function toString(): string
    {
        $exception = new \Exception($this->message);
        $reflection = new \ReflectionProperty($exception, 'trace');
        $reflection->setValue($exception, $this->trace);

        return ($this->originatesFromAnObject() ? 'deprecation triggered by '.$this->originatingClass().'::'.$this->originatingMethod().":\n" : '')
            .$this->message."\n"
            ."Stack trace:\n"
            .str_replace(' '.getcwd().\DIRECTORY_SEPARATOR, ' ', $exception->getTraceAsString())."\n";
    }
}
