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

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestResult;
use PHPUnit\Runner\ErrorHandler;
use PHPUnit\Util\Error\Handler;
use PHPUnit\Util\ErrorHandler as UtilErrorHandler;
use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Configuration;
use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\Deprecation;
use Symfony\Bridge\PhpUnit\DeprecationErrorHandler\DeprecationGroup;
use Symfony\Component\ErrorHandler\DebugClassLoader;

/**
 * Catch deprecation notices and print a summary report at the end of the test suite.
 *
 * @author Nicolas Grekas <p@tchwork.com>
 */
class DeprecationErrorHandler
{
    public const MODE_DISABLED = 'disabled';
    public const MODE_WEAK = 'max[total]=999999&verbose=0';
    public const MODE_STRICT = 'max[total]=0';

    private $mode;
    private $configuration;

    /**
     * @var DeprecationGroup[]
     */
    private $deprecationGroups = [];

    private static $isRegistered = false;
    private static $errorHandler;

    public function __construct()
    {
        $this->resetDeprecationGroups();
    }

    /**
     * Registers and configures the deprecation handler.
     *
     * The mode is a query string with options:
     *  - "disabled" to enable/disable the deprecation handler
     *  - "verbose" to enable/disable displaying the deprecation report
     *  - "quiet" to disable displaying the deprecation report only for some groups (i.e. quiet[]=other)
     *  - "max" to configure the number of deprecations to allow before exiting with a non-zero
     *    status code; it's an array with keys "total", "self", "direct" and "indirect"
     *
     * The default mode is "max[total]=0&verbose=1".
     *
     * The mode can alternatively be "/some-regexp/" to stop the test suite whenever
     * a deprecation message matches the given regular expression.
     *
     * @param int|string|false $mode The reporting mode, defaults to not allowing any deprecations
     */
    public static function register($mode = 0)
    {
        if (self::$isRegistered) {
            return;
        }

        $handler = new self();
        $oldErrorHandler = set_error_handler([$handler, 'handleError']);

        if (null !== $oldErrorHandler) {
            restore_error_handler();

            if (
                $oldErrorHandler instanceof UtilErrorHandler
                || [UtilErrorHandler::class, 'handleError'] === $oldErrorHandler
                || $oldErrorHandler instanceof ErrorHandler
                || [ErrorHandler::class, 'handleError'] === $oldErrorHandler
            ) {
                restore_error_handler();
                self::register($mode);
            }
        } else {
            $handler->mode = $mode;
            self::$isRegistered = true;
            register_shutdown_function([$handler, 'shutdown']);
        }
    }

    public static function collectDeprecations($outputFile)
    {
        $deprecations = [];
        $previousErrorHandler = set_error_handler(function ($type, $msg, $file, $line, $context = []) use (&$deprecations, &$previousErrorHandler) {
            if (\E_USER_DEPRECATED !== $type && \E_DEPRECATED !== $type && (\E_WARNING !== $type || false === strpos($msg, '" targeting switch is equivalent to "break'))) {
                if ($previousErrorHandler) {
                    return $previousErrorHandler($type, $msg, $file, $line, $context);
                }

                return \call_user_func(self::getPhpUnitErrorHandler(), $type, $msg, $file, $line, $context);
            }

            $filesStack = [];
            foreach (debug_backtrace() as $frame) {
                if (!isset($frame['file']) || \in_array($frame['function'], ['require', 'require_once', 'include', 'include_once'], true)) {
                    continue;
                }

                $filesStack[] = $frame['file'];
            }

            $deprecations[] = [error_reporting() & $type, $msg, $file, $filesStack];

            return null;
        });

        register_shutdown_function(function () use ($outputFile, &$deprecations) {
            file_put_contents($outputFile, serialize($deprecations));
        });
    }

    /**
     * @internal
     */
    public function handleError($type, $msg, $file, $line, $context = [])
    {
        if ((\E_USER_DEPRECATED !== $type && \E_DEPRECATED !== $type && (\E_WARNING !== $type || false === strpos($msg, '" targeting switch is equivalent to "break'))) || !$this->getConfiguration()->isEnabled()) {
            return \call_user_func(self::getPhpUnitErrorHandler(), $type, $msg, $file, $line, $context);
        }

        $trace = debug_backtrace();

        if (isset($trace[1]['function'], $trace[1]['args'][0]) && ('trigger_error' === $trace[1]['function'] || 'user_error' === $trace[1]['function'])) {
            $msg = $trace[1]['args'][0];
        }

        $deprecation = new Deprecation($msg, $trace, $file, \E_DEPRECATED === $type);
        if ($deprecation->isMuted()) {
            return null;
        }
        if ($this->getConfiguration()->isIgnoredDeprecation($deprecation)) {
            return null;
        }
        if ($this->getConfiguration()->isBaselineDeprecation($deprecation)) {
            return null;
        }

        $msg = $deprecation->getMessage();

        if (\E_DEPRECATED !== $type && (error_reporting() & $type)) {
            $group = 'unsilenced';
        } elseif ($deprecation->isLegacy()) {
            $group = 'legacy';
        } else {
            $group = [
                Deprecation::TYPE_SELF => 'self',
                Deprecation::TYPE_DIRECT => 'direct',
                Deprecation::TYPE_INDIRECT => 'indirect',
                Deprecation::TYPE_UNDETERMINED => 'other',
            ][$deprecation->getType()];
        }

        if ($this->getConfiguration()->shouldDisplayStackTrace($msg)) {
            echo "\n".ucfirst($group).' '.$deprecation->toString();

            exit(1);
        }

        if (\PHP_VERSION_ID >= 80500 && \in_array($msg, [
            'The __sleep() serialization magic method has been deprecated. Implement __serialize() instead (or in addition, if support for old PHP versions is necessary)',
            'The __wakeup() serialization magic method has been deprecated. Implement __unserialize() instead (or in addition, if support for old PHP versions is necessary)',
        ], true)) {
            return null;
        }

        if ('legacy' === $group) {
            $this->deprecationGroups[$group]->addNotice();
        } elseif ($deprecation->originatesFromAnObject()) {
            $class = $deprecation->originatingClass();
            $method = $deprecation->originatingMethod();
            $this->deprecationGroups[$group]->addNoticeFromObject($msg, $class, $method);
        } else {
            $this->deprecationGroups[$group]->addNoticeFromProceduralCode($msg);
        }

        return null;
    }

    /**
     * @internal
     */
    public function shutdown()
    {
        $configuration = $this->getConfiguration();

        if ($configuration->isInRegexMode()) {
            return;
        }

        if (class_exists(DebugClassLoader::class, false)) {
            DebugClassLoader::checkClasses();
        }
        $currErrorHandler = set_error_handler('is_int');
        restore_error_handler();

        if ($currErrorHandler !== [$this, 'handleError']) {
            echo "\n", self::colorize('THE ERROR HANDLER HAS CHANGED!', true), "\n";
        }

        $groups = array_keys($this->deprecationGroups);

        // store failing status
        $isFailing = !$configuration->tolerates($this->deprecationGroups);

        $this->displayDeprecations($groups, $configuration);

        $this->resetDeprecationGroups();

        register_shutdown_function(function () use ($isFailing, $groups, $configuration) {
            foreach ($this->deprecationGroups as $group) {
                if ($group->count() > 0) {
                    echo "Shutdown-time deprecations:\n";
                    break;
                }
            }

            $isFailingAtShutdown = !$configuration->tolerates($this->deprecationGroups);
            $this->displayDeprecations($groups, $configuration);

            if ($configuration->isGeneratingBaseline()) {
                $configuration->writeBaseline();
            }

            if ($isFailing || $isFailingAtShutdown) {
                exit(1);
            }
        });
    }

    private function resetDeprecationGroups()
    {
        $this->deprecationGroups = [
            'unsilenced' => new DeprecationGroup(),
            'self' => new DeprecationGroup(),
            'direct' => new DeprecationGroup(),
            'indirect' => new DeprecationGroup(),
            'legacy' => new DeprecationGroup(),
            'other' => new DeprecationGroup(),
        ];
    }

    private function getConfiguration()
    {
        if (null !== $this->configuration) {
            return $this->configuration;
        }
        if (false === $mode = $this->mode) {
            $mode = $_SERVER['SYMFONY_DEPRECATIONS_HELPER'] ?? $_ENV['SYMFONY_DEPRECATIONS_HELPER'] ?? getenv('SYMFONY_DEPRECATIONS_HELPER');
        }
        if ('strict' === $mode) {
            return $this->configuration = Configuration::inStrictMode();
        }
        if (self::MODE_DISABLED === $mode) {
            return $this->configuration = Configuration::inDisabledMode();
        }
        if ('weak' === $mode) {
            return $this->configuration = Configuration::inWeakMode();
        }
        if (isset($mode[0]) && '/' === $mode[0]) {
            return $this->configuration = Configuration::fromRegex($mode);
        }

        if (preg_match('/^[1-9][0-9]*$/', (string) $mode)) {
            return $this->configuration = Configuration::fromNumber($mode);
        }

        if (!$mode) {
            return $this->configuration = Configuration::fromNumber(0);
        }

        return $this->configuration = Configuration::fromUrlEncodedString((string) $mode);
    }

    /**
     * @param string $str
     * @param bool   $red
     *
     * @return string
     */
    private static function colorize($str, $red)
    {
        if (!self::hasColorSupport()) {
            return $str;
        }

        $color = $red ? '41;37' : '43;30';

        return "\x1B[{$color}m{$str}\x1B[0m";
    }

    /**
     * @param string[]      $groups
     * @param Configuration $configuration
     *
     * @throws \InvalidArgumentException
     */
    private function displayDeprecations($groups, $configuration)
    {
        $cmp = function ($a, $b) {
            return $b->count() - $a->count();
        };

        if ($configuration->shouldWriteToLogFile()) {
            if (false === $handle = @fopen($file = $configuration->getLogFile(), 'a')) {
                throw new \InvalidArgumentException(\sprintf('The configured log file "%s" is not writeable.', $file));
            }
        } else {
            $handle = fopen('php://output', 'w');
        }

        foreach ($groups as $group) {
            if ($this->deprecationGroups[$group]->count()) {
                $deprecationGroupMessage = \sprintf(
                    '%s deprecation notices (%d)',
                    \in_array($group, ['direct', 'indirect', 'self'], true) ? "Remaining $group" : ucfirst($group),
                    $this->deprecationGroups[$group]->count()
                );
                if ($configuration->shouldWriteToLogFile()) {
                    fwrite($handle, "\n$deprecationGroupMessage\n");
                } else {
                    fwrite($handle, "\n".self::colorize($deprecationGroupMessage, 'legacy' !== $group && 'indirect' !== $group)."\n");
                }

                // Skip the verbose output if the group is quiet and not failing according to its threshold:
                if ('legacy' !== $group && !$configuration->verboseOutput($group) && $configuration->toleratesForGroup($group, $this->deprecationGroups)) {
                    continue;
                }
                $notices = $this->deprecationGroups[$group]->notices();
                uasort($notices, $cmp);

                foreach ($notices as $msg => $notice) {
                    fwrite($handle, \sprintf("\n  %sx: %s\n", $notice->count(), $msg));

                    $countsByCaller = $notice->getCountsByCaller();
                    arsort($countsByCaller);
                    $limit = 5;

                    foreach ($countsByCaller as $method => $count) {
                        if ('count' !== $method) {
                            if (!$limit--) {
                                fwrite($handle, "    ...\n");
                                break;
                            }
                            fwrite($handle, \sprintf("    %dx in %s\n", $count, preg_replace('/(.*)\\\\(.*?::.*?)$/', '$2 from $1', $method)));
                        }
                    }
                }
            }
        }

        if (!empty($notices)) {
            fwrite($handle, "\n");
        }
    }

    private static function getPhpUnitErrorHandler(): callable
    {
        if (!$eh = self::$errorHandler) {
            if (class_exists(Handler::class)) {
                $eh = self::$errorHandler = Handler::class;
            } elseif (method_exists(UtilErrorHandler::class, '__invoke')) {
                $eh = self::$errorHandler = UtilErrorHandler::class;
            } elseif (method_exists(ErrorHandler::class, '__invoke')) {
                $eh = self::$errorHandler = ErrorHandler::class;
            } else {
                return self::$errorHandler = 'PHPUnit\Util\ErrorHandler::handleError';
            }
        }

        if ('PHPUnit\Util\ErrorHandler::handleError' === $eh) {
            return $eh;
        }

        foreach (debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT | \DEBUG_BACKTRACE_IGNORE_ARGS) as $frame) {
            if (!isset($frame['object'])) {
                continue;
            }

            if ($frame['object'] instanceof TestResult) {
                return new $eh(
                    $frame['object']->getConvertDeprecationsToExceptions(),
                    $frame['object']->getConvertErrorsToExceptions(),
                    $frame['object']->getConvertNoticesToExceptions(),
                    $frame['object']->getConvertWarningsToExceptions()
                );
            } elseif (ErrorHandler::class === $eh && $frame['object'] instanceof TestCase) {
                return function (int $errorNumber, string $errorString, string $errorFile, int $errorLine) {
                    ErrorHandler::instance()($errorNumber, $errorString, $errorFile, $errorLine);

                    return true;
                };
            }
        }

        return function () { return false; };
    }

    /**
     * Returns true if STDOUT is defined and supports colorization.
     *
     * Reference: Composer\XdebugHandler\Process::supportsColor
     * https://github.com/composer/xdebug-handler
     *
     * @return bool
     */
    private static function hasColorSupport()
    {
        if (!\defined('STDOUT')) {
            return false;
        }

        // Follow https://no-color.org/
        if ('' !== (($_SERVER['NO_COLOR'] ?? getenv('NO_COLOR'))[0] ?? '')) {
            return false;
        }

        if (!self::isTty()) {
            return false;
        }

        if ('\\' === \DIRECTORY_SEPARATOR && \function_exists('sapi_windows_vt100_support') && @sapi_windows_vt100_support(\STDOUT)) {
            return true;
        }

        if ('Hyper' === getenv('TERM_PROGRAM')
            || false !== getenv('COLORTERM')
            || false !== getenv('ANSICON')
            || 'ON' === getenv('ConEmuANSI')
        ) {
            return true;
        }

        if ('dumb' === $term = (string) getenv('TERM')) {
            return false;
        }

        // See https://github.com/chalk/supports-color/blob/d4f413efaf8da045c5ab440ed418ef02dbb28bf1/index.js#L157
        return preg_match('/^((screen|xterm|vt100|vt220|putty|rxvt|ansi|cygwin|linux).*)|(.*-256(color)?(-bce)?)$/', $term);
    }

    /**
     * Checks if the stream is a TTY, i.e; whether the output stream is connected to a terminal.
     *
     * Reference: Composer\Util\Platform::isTty
     * https://github.com/composer/composer
     */
    private static function isTty(): bool
    {
        // Detect msysgit/mingw and assume this is a tty because detection
        // does not work correctly, see https://github.com/composer/composer/issues/9690
        if (\in_array(strtoupper((string) getenv('MSYSTEM')), ['MINGW32', 'MINGW64'], true)) {
            return true;
        }

        // Modern cross-platform function, includes the fstat fallback so if it is present we trust it
        if (\function_exists('stream_isatty')) {
            return @stream_isatty(\STDOUT);
        }

        // Only trusting this if it is positive, otherwise prefer fstat fallback.
        if (\function_exists('posix_isatty') && @posix_isatty(\STDOUT)) {
            return true;
        }

        $stat = @fstat(\STDOUT);

        // Check if formatted mode is S_IFCHR
        return $stat ? 0020000 === ($stat['mode'] & 0170000) : false;
    }
}
