<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Extbase\Utility;

use TYPO3\CMS\Core\Core\RequestId;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\DomainObject\AbstractValueObject;
use TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface;
use TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy;
use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper;
use TYPO3\CMS\Extbase\Persistence\Generic\PersistenceManager;
use TYPO3\CMS\Extbase\Persistence\Generic\Qom\QueryObjectModelFactory;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Extbase\Reflection\ReflectionService;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;

/**
 * This class is a backport of the corresponding class of TYPO3 Flow.
 * All credits go to the TYPO3 Flow team.
 *
 * A debugging utility class
 */
class DebuggerUtility
{
    public const PLAINTEXT_INDENT = '   ';

    /**
     * @var \TYPO3\CMS\Extbase\Persistence\ObjectStorage
     */
    protected static $renderedObjects;

    /**
     * Hardcoded list of Extbase class names (regex) which should not be displayed during debugging
     *
     * @var array
     */
    protected static $blacklistedClassNames = [
        ReflectionService::class,
        DataMapper::class,
        PersistenceManager::class,
        QueryObjectModelFactory::class,
        ContentObjectRenderer::class,
    ];

    /**
     * Hardcoded list of property names (regex) which should not be displayed during debugging
     *
     * @var array
     */
    protected static $blacklistedPropertyNames = ['warning'];

    /**
     * Is set to TRUE once the CSS file is included in the current page to prevent double inclusions of the CSS file.
     *
     * @var bool
     */
    protected static $stylesheetEchoed = false;

    /**
     * Defines the max recursion depth of the dump, set to 8 due to common memory limits
     *
     * @var int
     */
    protected static $maxDepth = 8;

    /**
     * Clear the state of the debugger
     */
    protected static function clearState(): void
    {
        self::$renderedObjects = new ObjectStorage();
    }

    /**
     * Renders a dump of the given value
     */
    protected static function renderDump($value, int $level, bool $plainText, bool $ansiColors, array $headerPrefix = []): array|callable|string
    {
        $dump = [];
        if (is_string($value)) {
            $croppedValue = mb_strlen($value) > 2000 ? mb_substr($value, 0, 2000) . '...' : $value;
            if ($plainText) {
                $dump = [
                    self::ansiEscapeWrap('"' . implode(PHP_EOL . str_repeat(self::PLAINTEXT_INDENT, $level + 1), mb_str_split($croppedValue, 76)) . '"', '33', $plainText, $ansiColors),
                    ' (',
                    mb_strlen($value),
                    ' chars)',
                ];
            } else {
                $lines = mb_str_split($croppedValue, 76);
                $content = [];
                foreach ($lines as $key => $line) {
                    if ($key > 0) {
                        $content[] = self::html('br', []);
                        $content[] = static fn(): string => '&nbsp;';
                    }
                    $content[] = $line;
                }

                $dump = self::html('span', ['class' => 'extbase-debug-string-container'], [
                    '\'',
                    self::html('span', ['class' => 'extbase-debug-string'], $content),
                    '\' (',
                    mb_strlen($value),
                    ' chars)',
                ]);
            }
        } elseif (is_numeric($value)) {
            $dump = [
                self::ansiEscapeWrap((string)$value, '35', $plainText, $ansiColors),
                ' (',
                gettype($value),
                ')',
            ];
        } elseif (is_bool($value)) {
            $dump = $value ? self::ansiEscapeWrap('TRUE', '32', $plainText, $ansiColors) : self::ansiEscapeWrap('FALSE', '32', $plainText, $ansiColors);
        } elseif ($value === null || is_resource($value)) {
            $dump = gettype($value);
        } elseif (is_array($value)) {
            return self::renderArray($value, $level + 1, $plainText, $ansiColors, $headerPrefix);
        } elseif (is_object($value)) {
            if ($value instanceof \Closure) {
                return self::renderClosure($value, $level + 1, $plainText, $ansiColors, $headerPrefix);
            }
            return self::renderObject($value, $level + 1, $plainText, $ansiColors, $headerPrefix);

        }
        if ($plainText) {
            return [$headerPrefix, $dump];
        }
        return self::html('div', ['class' => 'extbase-debug-header'], [$headerPrefix, $dump]);
    }

    /**
     * Renders a dump of the given array
     */
    protected static function renderArray(array $array, int $level, bool $plainText = false, bool $ansiColors = false, array $headerPrefix = []): array|callable
    {
        $content = [];
        $count = count($array);

        $header = $headerPrefix;
        $header[] = self::styled('', 'expander', $plainText, $ansiColors);
        $header[] = self::styled('array', 'type', $plainText, $ansiColors, 0, 1);
        $header[] = $count > 0 ? '(' . $count . ' item' . ($count > 1 ? 's' : '') . ')' : '(empty)';
        if ($level >= self::$maxDepth) {
            $header[] = self::styled('max depth', 'filtered', $plainText, $ansiColors, 1);
        } else {
            $content = self::renderCollection($array, $level, $plainText, $ansiColors);
        }

        if ($plainText) {
            return [...$header, ...$content];
        }

        if (array_filter($content) === []) {
            return self::html('div', ['class' => 'extbase-debug-header'], $header);
        }
        return self::html('details', ['class' => 'extbase-debugger-tree', 'open' => $level > 1 && $count > 0 ? null : ''], [
            self::html('summary', ['class' => 'extbase-debug-header'], $header),
            self::html('div', ['class' => 'extbase-debug-content'], $content),
        ]);
    }

    /**
     * Renders a dump of the given object
     */
    protected static function renderObject(object $object, int $level, bool $plainText = false, bool $ansiColors = false, array $headerPrefix = []): array|callable
    {
        if ($object instanceof LazyLoadingProxy) {
            $object = $object->_loadRealInstance();
            if (!is_object($object)) {
                return [...$headerPrefix, gettype($object)];
            }
        }
        $header = self::renderHeader($object, $level, $plainText, $ansiColors, $headerPrefix);
        $content = [];
        if ($level < self::$maxDepth && !self::isBlacklisted($object) && !(self::isAlreadyRendered($object) && $plainText !== true)) {
            $content = self::renderContent($object, $level, $plainText, $ansiColors);
        }
        if ($plainText) {
            return [...$header, ...$content];
        }

        if (array_filter($content) === []) {
            return self::html('div', ['class' => 'extbase-debugger-header'], $header);
        }
        return self::html('details', ['class' => 'extbase-debugger-tree', 'open' => $level > 1 ? null : ''], [
            self::html('summary', ['class' => 'extbase-debug-header', 'id' => spl_object_hash($object)], $header),
            self::html('div', ['class' => 'extbase-debug-content'], $content),
        ]);
    }

    /**
     * Renders a dump of the given closure
     */
    protected static function renderClosure(\Closure $object, int $level, bool $plainText = false, bool $ansiColors = false, array $headerPrefix = []): array|callable
    {
        $header = self::renderHeader($object, $level, $plainText, $ansiColors, $headerPrefix);
        $content = [];
        if ($level < self::$maxDepth && (!self::isAlreadyRendered($object) || $plainText)) {
            $content = self::renderContent($object, $level, $plainText, $ansiColors);
        }
        if ($plainText) {
            return [...$header, ...$content];
        }
        if (array_filter($content) === []) {
            return self::html('div', ['class' => 'extbase-debugger-header'], $header);
        }
        return self::html('details', ['class' => 'extbase-debugger-tree', 'open' => $level > 1 ? null : ''], [
            self::html('summary', ['class' => 'extbase-debug-header'], $header),
            self::html('div', ['class' => 'extbase-debug-content extbase-debug-closure'], $content),
        ]);
    }

    /**
     * Checks if a given object or property should be excluded/filtered
     *
     * @param object $value A ReflectionProperty or other Object
     * @return bool TRUE if the given object should be filtered
     */
    protected static function isBlacklisted(object $value): bool
    {
        if ($value instanceof \ReflectionProperty) {
            $result = in_array($value->getName(), self::$blacklistedPropertyNames, true);
        } else {
            $result = in_array(get_class($value), self::$blacklistedClassNames, true);
        }
        return $result;
    }

    /**
     * Checks if a given object was already rendered.
     *
     * @return bool TRUE if the given object was already rendered
     */
    protected static function isAlreadyRendered(object $object): bool
    {
        return self::$renderedObjects->contains($object);
    }

    /**
     * Renders the header of a given object/collection. It is usually the class name along with some flags.
     *
     * @return array<string|callable> string The rendered header with tags
     */
    protected static function renderHeader(object $object, int $level, bool $plainText, bool $ansiColors, array $headerPrefix): array
    {
        $dump = $headerPrefix;
        $persistenceType = null;
        $className = get_class($object);
        $classReflection = new \ReflectionClass($className);
        $dump[] = self::styled('', 'expander', $plainText, $ansiColors);
        $dump[] = self::styled($className, 'type', $plainText, $ansiColors);

        if (!$object instanceof \Closure) {
            if ($object instanceof SingletonInterface) {
                $scope = 'singleton';
            } else {
                $scope = 'prototype';
            }
            $dump[] = self::styled($scope, 'scope', $plainText, $ansiColors, 1);
            if ($object instanceof DomainObjectInterface) {
                if ($object->_isDirty()) {
                    $persistenceType = 'modified';
                } elseif ($object->_isNew()) {
                    $persistenceType = 'transient';
                } else {
                    $persistenceType = 'persistent';
                }
            }
            if ($object instanceof ObjectStorage && $object->_isDirty()) {
                $persistenceType = 'modified';
            }
            if ($object instanceof AbstractEntity) {
                $domainObjectType = 'entity';
            } elseif ($object instanceof AbstractValueObject) {
                $domainObjectType = 'valueobject';
            } else {
                $domainObjectType = 'object';
            }
            $persistenceType = $persistenceType === null ? '' : $persistenceType . ' ';
            $dump[] = self::styled($persistenceType . $domainObjectType, 'ptype', $plainText, $ansiColors, 1);
        }

        if (strpos(implode('|', self::$blacklistedClassNames), get_class($object)) > 0) {
            $dump[] = self::styled('filtered', 'filtered', $plainText, $ansiColors, 1);
        } elseif (self::$renderedObjects->contains($object) && !$plainText) {
            $dump = [
                self::html('a', ['href' => '#' . spl_object_hash($object), 'class' => 'extbase-debug-seeabove'], [
                    $dump,
                    self::html('span', ['class' => 'extbase-debug-filtered'], 'see above'),
                ]),
            ];
        } elseif ($level >= self::$maxDepth && !$object instanceof \DateTimeInterface) {
            $dump[] = self::styled('max depth', 'filtered', $plainText, $ansiColors, 1);
        }

        if ($object instanceof \Countable) {
            $objectCount = count($object);
            $dump[] = $objectCount > 0 ? ' (' . $objectCount . ' items)' : ' (empty)';
        }
        if ($object instanceof \DateTimeInterface) {
            $dump[] = ' (' . $object->format(\DateTimeInterface::RFC3339) . ', ' . $object->getTimestamp() . ')';
        }
        if ($object instanceof DomainObjectInterface && !$object->_isNew()) {
            $dump[] = ' (uid=' . $object->getUid() . ', pid=' . $object->getPid() . ')';
        }

        return $dump;
    }

    protected static function renderContent(object $object, int $level, bool $plainText, bool $ansiColors): string|array
    {
        $dump = [];
        if ($object instanceof \Iterator || $object instanceof \ArrayObject) {
            $dump[] = self::renderCollection($object, $level, $plainText, $ansiColors);
        } else {
            self::$renderedObjects->attach($object);
            if ($object instanceof \Closure) {
                if ($plainText) {
                    $dump[] = PHP_EOL;
                }
                $dump[] = str_repeat(self::PLAINTEXT_INDENT, $level);
                $dump[] = self::styled('function (', 'closure', $plainText, $ansiColors);

                $reflectionFunction = new \ReflectionFunction($object);
                $params = [];
                $count = 0;
                foreach ($reflectionFunction->getParameters() as $parameter) {
                    if (++$count > 1) {
                        $params[] = ', ';
                    }
                    $isFirst = false;
                    $type = $parameter->getType();
                    // @todo    Following code adds for parameter of type array or a class the classname or array
                    //          to the output. All other introduced possible parameter types are not respected yet.
                    //          This should be extended, and also respect possible type combinations like
                    //          union types and union intersect types.
                    if ($type instanceof \ReflectionNamedType && $type->isBuiltin() && $type->getName() === 'array') {
                        $params[] = self::styled('array', 'type', $plainText, $ansiColors, 0, 1);
                    } elseif ($type instanceof \ReflectionNamedType && !$type->isBuiltin() && !empty($type->getName())) {
                        $params[] = self::styled($type->getName(), 'type', $plainText, $ansiColors, 0, 1);
                    }
                    if ($parameter->isPassedByReference()) {
                        $params[] = '&';
                    }
                    if ($parameter->isVariadic()) {
                        $params[] = '...';
                    }
                    $params[] = self::styled('$' . $parameter->name, 'property', $plainText, $ansiColors);
                    if ($parameter->isDefaultValueAvailable()) {
                        $params[] = ' = ';
                        $params[] = self::styled(var_export($parameter->getDefaultValue(), true), 'string', $plainText, $ansiColors);
                    }
                }
                $dump = [...$dump, ...$params];
                $dump[] = self::styled(') {', 'closure', $plainText, $ansiColors);
                $dump[] = PHP_EOL;

                $lines = (array)file((string)$reflectionFunction->getFileName());
                for ($l = (int)$reflectionFunction->getStartLine(); $l < (int)$reflectionFunction->getEndLine() - 1; ++$l) {
                    $line = (string)($lines[$l] ?? '');
                    $dump[] = $line;
                }
                $dump[] = str_repeat(self::PLAINTEXT_INDENT, $level);
                $dump[] = self::styled('}', 'closure', $plainText, $ansiColors);
                if ($plainText) {
                    $dump[] = PHP_EOL;
                }
            } else {
                if (get_class($object) === \stdClass::class) {
                    $objReflection = new \ReflectionObject($object);
                    $properties = $objReflection->getProperties();
                } else {
                    $classReflection = new \ReflectionClass(get_class($object));
                    $properties = $classReflection->getProperties();
                }
                foreach ($properties as $property) {
                    if (self::isBlacklisted($property)) {
                        continue;
                    }
                    $visibility = ($property->isProtected() ? 'protected' : ($property->isPrivate() ? 'private' : 'public'));
                    $header = [
                        PHP_EOL . str_repeat(self::PLAINTEXT_INDENT, $level),
                        self::styled($property->getName(), 'property', $plainText, $ansiColors),
                        ' => ',
                        self::styled($visibility, 'visibility', $plainText, $ansiColors, 0, 1),
                    ];
                    if (!$property->isInitialized($object)) {
                        $header[] = self::styled('uninitialized', 'uninitialized', $plainText, $ansiColors, 0, 1);
                        if ($plainText) {
                            $dump[] = $header;
                        } else {
                            $dump[] = self::html('div', ['class' => 'extbase-debug-header'], $header);
                        }
                        continue;
                    }
                    if ($object instanceof DomainObjectInterface && !$object->_isNew() && $object->_isDirty($property->getName())) {
                        $header[] = self::styled('modified', 'dirty', $plainText, $ansiColors, 1);
                    }
                    $dump[] = self::renderDump($property->getValue($object), $level, $plainText, $ansiColors, $header);
                }
            }
        }
        return $dump;
    }

    protected static function renderCollection(iterable $collection, int $level, bool $plainText, bool $ansiColors): array
    {
        $dump = [];
        foreach ($collection as $key => $value) {
            // Note: Due to the TYPO3\CMS\Core\Type\Map implementation, the key can also be an object.
            $key = is_object($key) ? get_class($key) : (string)$key;

            $dump = [
                ...$dump,
                self::renderDump($value, $level, $plainText, $ansiColors, [
                    PHP_EOL . str_repeat(self::PLAINTEXT_INDENT, $level),
                    self::styled($key, 'property', $plainText, $ansiColors),
                    ' => ',
                ]),
            ];
        }
        if ($collection instanceof \Iterator && !$collection instanceof \Generator) {
            $collection->rewind();
        }
        return $dump;
    }

    /**
     * Wrap a string with the ANSI escape sequence for colorful output
     *
     * @param string $string The string to wrap
     * @param string $ansiColorSequence The ansi color sequence (e.g. "1;37")
     * @param bool $plainText If FALSE, the string will be HTML encoded
     * @param bool $ansiColors If TRUE, the string will have console colors applied
     * @return callable The wrapped or raw string
     */
    protected static function ansiEscapeWrap(string $string, string $ansiColorSequence, bool $plainText, bool $ansiColors): callable
    {
        if ($plainText && $ansiColors) {
            return static fn(): string => '[' . $ansiColorSequence . 'm' . self::escapeConsoleText($string) . '[0m';
        }
        if ($plainText) {
            return static fn(): string => self::escapeConsoleText($string);
        }
        return static fn(): string => self::escapeHtml($string);
    }

    /**
     * A var_dump function optimized for Extbase's object structures
     *
     * @param mixed $variable The value to dump
     * @param string $title optional custom title for the debug output
     * @param int $maxDepth Sets the max recursion depth of the dump. De- or increase the number according to your needs and memory limit.
     * @param bool $plainText If TRUE, the dump is in plain text, if FALSE the debug output is in HTML format.
     * @param bool $ansiColors If TRUE (default), ANSI color codes is added to the output, if FALSE the debug output not colored.
     * @param bool $return if TRUE, the dump is returned for custom post-processing (e.g. embed in custom HTML). If FALSE (default), the dump is directly displayed.
     * @param array $blacklistedClassNames An array of class names (RegEx) to be filtered. Default is an array of some common class names.
     * @param array $blacklistedPropertyNames An array of property names and/or array keys (RegEx) to be filtered. Default is an array of some common property names.
     * @return string if $return is TRUE, the dump is returned. By default, the dump is directly displayed, and nothing is returned.
     */
    public static function var_dump(
        $variable,
        ?string $title = null,
        int $maxDepth = 8,
        bool $plainText = false,
        bool $ansiColors = true,
        bool $return = false,
        ?array $blacklistedClassNames = null,
        ?array $blacklistedPropertyNames = null
    ): string {
        self::$maxDepth = $maxDepth;
        if ($title === null) {
            $title = 'Extbase Variable Dump';
        }
        $ansiColors = $plainText && $ansiColors;
        if ($ansiColors === true) {
            $title = '[1m' . $title . '[0m';
        }
        $backupBlacklistedClassNames = self::$blacklistedClassNames;
        if (is_array($blacklistedClassNames)) {
            self::$blacklistedClassNames = $blacklistedClassNames;
        }
        $backupBlacklistedPropertyNames = self::$blacklistedPropertyNames;
        if (is_array($blacklistedPropertyNames)) {
            self::$blacklistedPropertyNames = $blacklistedPropertyNames;
        }
        self::clearState();

        $css = self::cssTreeToString([
            '.extbase-debugger-tree' => [
                'position' => 'relative',
            ],
            '.extbase-debugger-tree summary' => [
                'list-style' => 'none',
                'cursor' => 'pointer',
                'white-space' => 'nowrap',
            ],
            '.extbase-debugger-tree:has(>summary:target)' => [
                'outline' => 'var(--typo3-debugger-outline, #101010) auto 1px',
                'padding' => '3px',
            ],
            '.extbase-debugger-tree :is(.extbase-debug-header)>*' => [
                'vertical-align' => 'top',
            ],
            '.extbase-debugger-tree .extbase-debug-expander' => [
                'position' => 'relative',
                'display' => 'none',
                'height' => '1em',
                'aspect-ratio' => '1',
                'margin' => '0 3px 0 0',
                'vertical-align' => '-12%',
                'cursor' => 'pointer',
            ],
            '.extbase-debugger-tree summary>.extbase-debug-expander' => [
                'display' => 'inline-block',
            ],
            // Hide expander on first level
            '.extbase-debugger-inner>details>summary>.extbase-debug-expander' => [
                'display' => 'none',
            ],
            '.extbase-debugger-tree .extbase-debug-expander::before' => [
                'content' => '""',
                'position' => 'absolute',
                'inset' => '0',
                'background-size' => '100%',
                'display' => 'inline-block',
                'background-image' => sprintf('url(data:image/svg+xml;base64,%s)', base64_encode(
                    '<?xml version="1.0" encoding="utf-8"?><svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="#888"><path d="M11,11H0V0h11V11z M10,1H1v9h9V1z"/><rect x="2" y="5" width="7" height="1"/><rect x="5" y="2" width="1" height="7"/></svg>'
                )),
            ],
            '.extbase-debugger-tree[open]>summary>.extbase-debug-expander::before' => [
                'background-image' => sprintf('url(data:image/svg+xml;base64,%s)', base64_encode(
                    '<?xml version="1.0" encoding="utf-8"?><svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="#888"><path d="M11,11H0V0h11V11z M10,1H1v9h9V1z"/><rect x="2" y="5" width="7" height="1"/></svg>'
                )),
            ],
            '.extbase-debugger-tree .extbase-debug-content' => [
                'padding-left' => '3ch',
            ],
            '.extbase-debugger' => [
                'display' => 'block',
                'text-align' => 'left',
                'background' => 'var(--typo3-debugger-bg, #2a2a2a)',
                'border' => '1px solid var(--typo3-debugger-border-color, #2a2a2a)',
                'box-shadow' => 'var(--typo3-debugger-box-shadow, 0 3px 0 rgba(0, 0, 0, .5))',
                'margin' => '20px',
                'overflow' => 'hidden',
                'border-radius' => 'var(--typo3-debugger-border-radius, 4px)',
            ],
            '.extbase-debugger-floating' => [
                'position' => 'relative',
                'z-index' => '99990',
            ],
            '.extbase-debugger-top' => [
                'background' => 'var(--typo3-debugger-top-bg, #444)',
                'font-size' => '12px',
                'font-family' => 'monospace',
                'color' => 'var(--typo3-debugger-top-color, #f1f1f1)',
                'padding' => '6px 15px',
            ],
            '.extbase-debugger-inner' => [
                'overflow-x' => 'auto',
            ],
            '.extbase-debugger-center' => [
                'padding' => '0 15px',
                'margin' => '15px 0',
                'background-image' => sprintf(
                    'repeating-linear-gradient(to bottom, transparent 0, transparent 20px, %1$s 20px, %1$s 40px)',
                    'var(--typo3-debugger-bg-variant, #252525)',
                ),
                'color' => 'var(--typo3-debugger-color-variant, #999)',
                'word-wrap' => 'break-word',
            ],
            '.extbase-debugger-center, .extbase-debugger-center :is(.extbase-debug-string, a, p, pre, strong)' => [
                'font-size' => '12px',
                'font-weight' => '400',
                'font-family' => 'monospace',
                'line-height' => '20px',
                'color' => 'var(--typo3-debugger-color, #f1f1f1)',
            ],
            '.extbase-debugger-center .extbase-debug-string-container' => [
                'display' => 'inline-block',
            ],
            '.extbase-debugger-center :is(.extbase-debug-filtered, .extbase-debug-proxy, .extbase-debug-ptype, .extbase-debug-visibility, .extbase-debug-uninitialized, .extbase-debug-scope, .extbase-debug-dirty)' => [
                'color' => '#fff',
                'font-size' => '10px',
                'line-height' => '18px',
                'padding' => '2px 4px',
                'margin-right' => '2px',
            ],
            '.extbase-debugger-center .extbase-debug-unregistered' => [
                'background-color' => 'var(--typo3-debugger-unregistered-bg, #dce1e8)',
            ],
            '.extbase-debugger-center .extbase-debug-scope' => [
                'background-color' => 'var(--typo3-debugger-scope-bg, #497AA2)',
            ],
            '.extbase-debugger-center .extbase-debug-ptype' => [
                'background-color' => 'var(--typo3-debugger-ptype-bg, #698747)',
            ],
            '.extbase-debugger-center .extbase-debug-visibility' => [
                'background-color' => 'var(--typo3-debugger-visibility-bg, #6c0787)',
            ],
            '.extbase-debugger-center .extbase-debug-uninitialized' => [
                'background-color' => 'var(--typo3-debugger-uninitializedy-bg, #698747)',
            ],
            '.extbase-debugger-center .extbase-debug-dirty' => [
                'background-color' => 'var(--typo3-debugger-dirty-bg, #664d00)',
            ],
            '.extbase-debugger-center .extbase-debug-filtered' => [
                'background-color' => 'var(--typo3-debugger-filtered-bg, #664d00)',
            ],
            '.extbase-debugger-center .extbase-debug-string' => [
                'color' => 'var(--typo3-debugger-string-color, #ce9178)',
                'white-space' => 'normal',
            ],
            '.extbase-debugger-center .extbase-debug-type' => [
                'color' => 'var(--typo3-debugger-type-color, #569CD6)',
                'padding-right' => '4px',
            ],
            '.extbase-debugger-center .extbase-debug-closure' => [
                'color' => 'var(--typo3-debugger-closure-color, #9BA223)',
                'white-space' => 'pre',
            ],
            '.extbase-debugger-center .extbase-debug-property' => [
                'color' => 'var(--typo3-debugger-property-color, #f1f1f1)',
            ],
            '.extbase-debugger-center .extbase-debug-seeabove' => [
                'display' => 'block',
                'text-decoration' => 'none',
                'font-style' => 'italic',
            ],
        ]);

        $style = '';
        if (!$plainText && self::$stylesheetEchoed === false) {
            $style = self::html(
                'style',
                ['nonce' => self::resolveNonceValue(Directive::StyleSrcElem)],
                static fn(): string => $css
            )();
            self::$stylesheetEchoed = true;
        }
        if ($plainText) {
            $output = $title . self::render(
                [
                    PHP_EOL,
                    self::renderDump($variable, 0, true, $ansiColors),
                    PHP_EOL,
                    PHP_EOL,
                ],
                self::escapeConsoleText(...)
            );

        } else {
            $output = self::html('div', ['class' => 'extbase-debugger ' . ($return ? 'extbase-debugger-inline' : 'extbase-debugger-floating')], [
                self::html('div', ['class' => 'extbase-debugger-top'], $title),
                self::html('div', ['class' => 'extbase-debugger-center'], [
                    self::html('div', ['class' => 'extbase-debugger-inner'], [
                        self::renderDump($variable, 0, false, false),
                    ]),
                ]),
            ])();
        }
        self::$blacklistedClassNames = $backupBlacklistedClassNames;
        self::$blacklistedPropertyNames = $backupBlacklistedPropertyNames;
        if ($return === true) {
            return $style . $output;
        }
        echo $style . $output;

        return '';
    }

    protected static function resolveNonceValue(Directive $directive): string
    {
        return GeneralUtility::makeInstance(RequestId::class)->nonce->consumeInline($directive);
    }

    protected static function styled(
        string $content,
        string $style,
        bool $plainText,
        bool $ansiColors,
        int $spaceBefore = 0,
        int $spaceAfter = 0,
    ): callable|string {
        $styleMap = [
            'expander' => '',
            'string' => '33',
            'closure' => '33',
            'type' => '36',
            'property' => '37',
            'ptype' => '42;30',
            'visibility' => '42;30',
            'dirty' => '43;30',
            'scope' => '44;37',
            'filtered' => '47;30',
            'uninitialized' => '45;37',
        ];
        if (!isset($styleMap[$style])) {
            throw new \InvalidArgumentException('Invalid debugger style: ' . $style, 1726659808);
        }

        if ($plainText) {
            if ($style === 'expander') {
                return '';
            }
            return static fn() => [
                str_repeat(' ', $spaceBefore),
                self::ansiEscapeWrap($content, $styleMap[$style], $plainText, $ansiColors),
                str_repeat(' ', $spaceAfter),
            ];
        }

        return self::html('span', ['class' => 'extbase-debug-' . $style], $content);
    }

    protected static function html(string $tagName, array $attributes, string|array|callable|null $content = null): callable
    {
        if ($tagName === '' || !preg_match('/^[a-zA-Z][a-zA-Z0-9-]*$/', $tagName)) {
            throw new \InvalidArgumentException('Invalid tag name', 1726659807);
        }
        return static fn(): string => implode('', [
            '<',
            $tagName,
            count($attributes) > 0 ? ' ' : '',
            // filter null attributes
            GeneralUtility::implodeAttributes(array_filter($attributes, static fn(?string $value): bool => $value !== null), true, true),
            '>',
            ...($content === null ? [] : [
                self::render($content, self::escapeHtml(...)),
                '</',
                $tagName,
                '>',
            ]),
        ]);
    }

    protected static function escapeHtml(string $text): string
    {
        return htmlspecialchars($text, ENT_HTML5, 'UTF-8');
    }

    protected static function escapeConsoleText(string $text): string
    {
        return preg_replace_callback(
            '/[\x00-\x1F\x7F]/u',
            static fn(array $matches): string => $matches[0] === PHP_EOL ? PHP_EOL : '\\x' . str_pad(dechex(ord($matches[0])), 2, '0', STR_PAD_LEFT),
            $text
        ) ?? $text;
    }

    protected static function render(string|callable|array $content, callable $escape): string
    {
        if (is_string($content)) {
            return $escape($content);
        }
        if ($content instanceof \Closure) {
            $content = $content();
            if (is_string($content)) {
                return $content;
            }
        }
        if (is_array($content)) {
            return implode('', array_map(static fn(string|callable|array $content): string => self::render($content, $escape), $content));
        }

        throw new \InvalidArgumentException('Invalid callable return type: ' . gettype($content), 1726673500);
    }

    /**
     * Converts a CSS tree to a CSS stylesheet string
     *
     * Example input:
     *
     *     [
     *         '.my-class' => [
     *             'display' => 'block',
     *             'color' => 'black',
     *         ],
     *         '.other-class' => [
     *             'display' => 'flex',
     *         ],
     *     ]
     *
     * Output:
     *     .my-class{display:block;color:black}
     *     .other-class{display:flex}
     *
     * @param array<string, array<string, string>> $cssTree
     */
    protected static function cssTreeToString(array $cssTree): string
    {
        $rules = array_map(
            static fn(string $selector): string => sprintf(
                '%s{%s}',
                $selector,
                implode(
                    ';',
                    array_map(
                        static fn(string $property): string => sprintf(
                            '%s:%s',
                            $property,
                            $cssTree[$selector][$property]
                        ),
                        array_keys($cssTree[$selector])
                    )
                )
            ),
            array_keys($cssTree)
        );
        $stylesheet = implode(PHP_EOL, $rules);
        // Optimize away uneeded whitespace
        return str_replace(', ', ',', $stylesheet);
    }
}
