<?php declare(strict_types=1);
/*
 * This file is part of PHPUnit.
 *
 * (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 PHPUnit\Logging\TestDox;

use const PHP_EOL;
use function array_key_exists;
use function array_keys;
use function array_map;
use function array_pop;
use function array_splice;
use function array_values;
use function assert;
use function class_exists;
use function explode;
use function gettype;
use function implode;
use function is_bool;
use function is_float;
use function is_int;
use function is_object;
use function is_scalar;
use function is_string;
use function method_exists;
use function preg_quote;
use function preg_replace;
use function preg_replace_callback_array;
use function rtrim;
use function sprintf;
use function str_contains;
use function str_ends_with;
use function str_replace;
use function str_starts_with;
use function strlen;
use function strtolower;
use function substr;
use function trim;
use function ucfirst;
use BackedEnum;
use PHPUnit\Event\Code\TestMethodBuilder;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Framework\TestCase;
use PHPUnit\Metadata\Parser\Registry as MetadataRegistry;
use PHPUnit\Metadata\TestDox;
use PHPUnit\Metadata\TestDoxFormatter;
use PHPUnit\Util\Color;
use PHPUnit\Util\Exporter;
use PHPUnit\Util\Filter;
use PHPUnit\Util\Sanitizer;
use ReflectionMethod;
use Stringable;
use Throwable;
use UnitEnum;

/**
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
 *
 * @internal This class is not covered by the backward compatibility promise for PHPUnit
 */
final class NamePrettifier
{
    /**
     * @var array<string, int>
     */
    private array $strings = [];

    /**
     * @var array<non-empty-string, string>
     */
    private array $prettifiedTestCases = [];

    /**
     * @var array<non-empty-string, true>
     */
    private array $erroredFormatters = [];

    /**
     * @param class-string $className
     */
    public function prettifyTestClassName(string $className): string
    {
        if (class_exists($className)) {
            $classLevelTestDox = MetadataRegistry::parser()->forClass($className)->isTestDox();

            if ($classLevelTestDox->isNotEmpty()) {
                $classLevelTestDox = $classLevelTestDox->asArray()[0];

                assert($classLevelTestDox instanceof TestDox);

                return $classLevelTestDox->text();
            }
        }

        $parts     = explode('\\', $className);
        $className = array_pop($parts);

        if (str_ends_with($className, 'Test')) {
            $className = substr($className, 0, strlen($className) - strlen('Test'));
        }

        if ($className === '') {
            $className = 'UnnamedTests';
        }

        if ($parts !== []) {
            $parts[]            = $className;
            $fullyQualifiedName = implode('\\', $parts);
        } else {
            $fullyQualifiedName = $className;
        }

        $result = preg_replace('/(?<=[[:lower:]])(?=[[:upper:]])/u', ' ', $className);

        assert($result !== null);

        if ($fullyQualifiedName !== $className) {
            return $result . ' (' . $fullyQualifiedName . ')';
        }

        return $result;
    }

    // NOTE: this method is on a hot path and very performance sensitive. change with care.
    public function prettifyTestMethodName(string $name): string
    {
        if ($name === '') {
            return '';
        }

        $string = rtrim($name, '0123456789');

        if (array_key_exists($string, $this->strings)) {
            $name = $string;
        } elseif ($string === $name) {
            $this->strings[$string] = 1;
        }

        if (str_starts_with($name, 'test_')) {
            $name = substr($name, 5);
        } elseif (str_starts_with($name, 'test')) {
            $name = substr($name, 4);
        }

        if ($name === '') {
            return '';
        }

        $name = ucfirst($name);

        $noUnderscore = str_replace('_', ' ', $name);

        if ($noUnderscore !== $name) {
            return trim($noUnderscore);
        }

        $buffer = preg_replace_callback_array(
            [
                '/(?!^)([A-Z])/' => static function (array $matches): string
                {
                    if (!isset($matches[1]) || !is_string($matches[1])) {
                        return ' ';
                    }

                    return ' ' . strtolower($matches[1]);
                },
                '/(\d+)/' => static function (array $matches): string
                {
                    if (!isset($matches[1]) || !is_string($matches[1])) {
                        return ' ';
                    }

                    return ' ' . $matches[1];
                },
            ],
            $name,
        );

        return trim((string) $buffer);
    }

    public function prettifyTestCase(TestCase $test, bool $colorize): string
    {
        $key = $test::class . '#' . $test->name();

        if ($test->usesDataProvider()) {
            $key .= '#' . $test->dataName();
        }

        if ($colorize) {
            $key .= '#colorize';
        }

        if (isset($this->prettifiedTestCases[$key])) {
            return $this->prettifiedTestCases[$key];
        }

        $metadataCollection = MetadataRegistry::parser()->forMethod($test::class, $test->name());
        $testDox            = $metadataCollection->isTestDox()->isMethodLevel();
        $callback           = $metadataCollection->isTestDoxFormatter();
        $isCustomized       = false;

        if ($testDox->isNotEmpty()) {
            $testDox = $testDox->asArray()[0];

            assert($testDox instanceof TestDox);

            [$result, $isCustomized] = $this->processTestDox($test, $testDox, $colorize);
        } elseif ($callback->isNotEmpty()) {
            $callback = $callback->asArray()[0];

            assert($callback instanceof TestDoxFormatter);

            [$result, $isCustomized] = $this->processTestDoxFormatter($test, $callback);
        } else {
            $result = $this->prettifyTestMethodName($test->name());
        }

        if (!$isCustomized && $test->usesDataProvider()) {
            $result .= $this->prettifyDataSet($test, $colorize);
        }

        $this->prettifiedTestCases[$key] = $result;

        return $result;
    }

    public function prettifyDataSet(TestCase $test, bool $colorize): string
    {
        if (!$colorize) {
            return $test->dataSetAsString();
        }

        if (is_int($test->dataName())) {
            return Color::dim(' with data set ') . Color::colorize('fg-cyan', (string) $test->dataName());
        }

        return Color::dim(' with ') . Color::colorize(
            'fg-cyan',
            Color::visualizeWhitespace(
                Sanitizer::sanitizeBidirectionalControlCharacters($test->dataName()),
            ),
        );
    }

    /**
     * @return array<non-empty-string, string>
     */
    private function mapTestMethodParameterNamesToProvidedDataValues(TestCase $test, bool $colorize): array
    {
        assert(method_exists($test, $test->name()));

        /** @noinspection PhpUnhandledExceptionInspection */
        $reflector = new ReflectionMethod($test::class, $test->name());

        $providedData       = [];
        $providedDataValues = $test->providedData();
        $i                  = 0;

        $dataName = $test->dataName();

        if (is_int($dataName)) {
            $providedData['$_dataName'] = (string) $dataName;
        } else {
            $providedData['$_dataName'] = Sanitizer::sanitizeBidirectionalControlCharacters($dataName);
        }

        foreach ($reflector->getParameters() as $parameter) {
            if (array_key_exists($parameter->getName(), $providedDataValues)) {
                $value = $providedDataValues[$parameter->getName()];
            } elseif (array_key_exists($i, $providedDataValues)) {
                $value = $providedDataValues[$i];
            } elseif ($parameter->isDefaultValueAvailable()) {
                $value = $parameter->getDefaultValue();
            } else {
                $value = null;
            }

            $i++;

            if (is_object($value)) {
                $value = $this->objectToString($value);
            }

            if (!is_scalar($value)) {
                $value = gettype($value);

                if ($value === 'NULL') {
                    $value = 'null';
                }
            }

            if (is_bool($value) || is_int($value) || is_float($value)) {
                $value = Exporter::export($value);
            }

            if ($value === '') {
                if ($colorize) {
                    $value = Color::colorize('dim,underlined', 'empty');
                } else {
                    $value = "''";
                }
            }

            $providedData['$' . $parameter->getName()] = str_replace(
                '$',
                '\\$',
                Sanitizer::sanitizeBidirectionalControlCharacters($value),
            );
        }

        if ($colorize) {
            $providedData = array_map(
                static fn (string $value) => Color::colorize('fg-cyan', Color::visualizeWhitespace($value, true)),
                $providedData,
            );
        }

        return $providedData;
    }

    private function objectToString(object $value): string
    {
        if ($value instanceof UnitEnum) {
            if ($value instanceof BackedEnum) {
                return (string) $value->value;
            }

            return $value->name;
        }

        if ($value instanceof Stringable || method_exists($value, '__toString')) {
            return (string) $value;
        }

        return $value::class;
    }

    /**
     * @return array{0: string, 1: bool}
     */
    private function processTestDox(TestCase $test, TestDox $testDox, bool $colorize): array
    {
        $placeholdersUsed = false;

        $result = $testDox->text();

        if (str_contains($result, '$')) {
            $annotation   = $result;
            $providedData = $this->mapTestMethodParameterNamesToProvidedDataValues($test, $colorize);

            $variables = array_map(
                static fn (string $variable): string => sprintf(
                    '/%s(?=\b)/',
                    preg_quote($variable, '/'),
                ),
                array_keys($providedData),
            );

            $result = preg_replace($variables, $providedData, $annotation);

            $placeholdersUsed = true;
        }

        assert($result !== null);

        return [$result, $placeholdersUsed];
    }

    /**
     * @return array{0: string, 1: bool}
     */
    private function processTestDoxFormatter(TestCase $test, TestDoxFormatter $formatter): array
    {
        $className           = $formatter->className();
        $methodName          = $formatter->methodName();
        $formatterIdentifier = $className . '::' . $methodName;

        if (isset($this->erroredFormatters[$formatterIdentifier])) {
            return [$this->prettifyTestMethodName($test->name()), false];
        }

        if (!method_exists($className, $methodName)) {
            EventFacade::emitter()->testTriggeredPhpunitError(
                TestMethodBuilder::fromTestCase($test, false),
                sprintf(
                    'Method %s::%s() cannot be used as a TestDox formatter because it does not exist',
                    $className,
                    $methodName,
                ),
            );

            $this->erroredFormatters[$formatterIdentifier] = true;

            return [$this->prettifyTestMethodName($test->name()), false];
        }

        $reflector = new ReflectionMethod($className, $methodName);

        if (!$reflector->isPublic()) {
            EventFacade::emitter()->testTriggeredPhpunitError(
                TestMethodBuilder::fromTestCase($test, false),
                sprintf(
                    'Method %s::%s() cannot be used as a TestDox formatter because it is not public',
                    $className,
                    $methodName,
                ),
            );

            $this->erroredFormatters[$formatterIdentifier] = true;

            return [$this->prettifyTestMethodName($test->name()), false];
        }

        if (!$reflector->isStatic()) {
            EventFacade::emitter()->testTriggeredPhpunitError(
                TestMethodBuilder::fromTestCase($test, false),
                sprintf(
                    'Method %s::%s() cannot be used as a TestDox formatter because it is not static',
                    $className,
                    $methodName,
                ),
            );

            $this->erroredFormatters[$formatterIdentifier] = true;

            return [$this->prettifyTestMethodName($test->name()), false];
        }

        $arguments = array_values($test->providedData());

        foreach ($reflector->getParameters() as $position => $parameter) {
            if ($parameter->getName() === '_dataName') {
                array_splice($arguments, $position, 0, [(string) $test->dataName()]);

                break;
            }
        }

        try {
            $result = $reflector->invokeArgs(null, $arguments);

            assert(is_string($result));

            return [$result, true];
        } catch (Throwable $t) {
            EventFacade::emitter()->testTriggeredPhpunitError(
                TestMethodBuilder::fromTestCase($test, false),
                sprintf(
                    'TestDox formatter %s::%s() triggered an error: %s%s%s',
                    $className,
                    $methodName,
                    $t->getMessage(),
                    PHP_EOL,
                    Filter::stackTraceFromThrowableAsString($t),
                ),
            );

            $this->erroredFormatters[$formatterIdentifier] = true;

            return [$this->prettifyTestMethodName($test->name()), false];
        }
    }
}
