<?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\Core\Error;

use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Information\Typo3Information;

/**
 * A basic but solid exception handler which catches everything which
 * falls through the other exception handlers and provides useful debugging
 * information.
 */
class DebugExceptionHandler extends AbstractExceptionHandler
{
    protected bool $logExceptionStackTrace = true;

    /**
     * Constructs this exception handler - registers itself as the default exception handler.
     */
    public function __construct()
    {
        set_exception_handler($this->handleException(...));
    }

    /**
     * Formats and echoes the exception as XHTML.
     *
     * @param \Throwable $exception The throwable object.
     */
    public function echoExceptionWeb(\Throwable $exception)
    {
        $this->sendStatusHeaders($exception);
        $this->writeLogEntries($exception, self::CONTEXT_WEB);

        $content = $this->getContent($exception);
        $css = $this->getStylesheet();
        $js = $this->getJavascript();

        echo <<<HTML
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>TYPO3 Exception</title>
        <meta name="robots" content="noindex,nofollow" />
        <style>$css</style>
        <script>$js</script>
    </head>
    <body>
        $content
    </body>
</html>
HTML;
    }

    /**
     * Formats and echoes the exception for the command line
     *
     * @param \Throwable $exception The throwable object.
     */
    public function echoExceptionCLI(\Throwable $exception)
    {
        $filePathAndName = $exception->getFile();
        $exceptionCodeNumber = $exception->getCode() > 0 ? '#' . $exception->getCode() . ': ' : '';
        $this->writeLogEntries($exception, self::CONTEXT_CLI);
        echo LF . 'Uncaught TYPO3 Exception ' . $exceptionCodeNumber . $exception->getMessage() . LF;
        echo 'thrown in file ' . $filePathAndName . LF;
        echo 'in line ' . $exception->getLine() . LF . LF;
        die(1);
    }

    /**
     * Generates the HTML for the error output.
     */
    protected function getContent(\Throwable $throwable): string
    {
        $content = '';

        // exceptions can be chained
        // for easier debugging, all exceptions are displayed to the developer
        $throwables = $this->getAllThrowables($throwable);
        $count = count($throwables);
        foreach ($throwables as $position => $e) {
            $content .= $this->getSingleThrowableContent($e, $position + 1, $count);
        }

        $exceptionInfo = '';
        if ($throwable->getCode() > 0) {
            $documentationLink = Typo3Information::URL_EXCEPTION . 'debug/' . $throwable->getCode();
            $exceptionInfo = <<<INFO
            <div class="container">
                <div class="callout">
                    <div class="callout-content">
                        <div class="callout-title">Get help in the TYPO3 Documentation</div>
                        <div class="callout-body">
                            <p>
                                If you need help solving this exception, you can have a look at the TYPO3 Documentation.
                                There you can find solutions provided by the TYPO3 community.
                                Once you have found a solution to the problem, help others by contributing to the
                                documentation page.
                            </p>
                            <p>
                                <a href="$documentationLink" target="_blank" rel="noreferrer">Find a solution for this exception in the TYPO3 Documentation.</a>
                                <span id="stacktrace-action-buttons"></span>
                            </p>
                        </div>
                    </div>
                </div>
            </div>
INFO;
        }

        $typo3Logo = $this->getTypo3LogoAsSvg();

        try {
            // This outside dependency class is always loaded before the exception handler is setup.
            // So it is safe to access without affecting the output of this handler.
            $projectPath = Environment::getProjectPath() . DIRECTORY_SEPARATOR;
        } catch (\Throwable) {
            // just in case something goes wrong.
            $projectPath = '';
        }

        $projectPathEscaped = $this->escapeHtml($projectPath);

        return <<<HTML
            <div class="exception-page" data-project-path="$projectPathEscaped">
                <div class="exception-summary">
                    <div class="container">
                        <div class="exception-message-wrapper">
                            <div class="exception-illustration hidden-xs-down">$typo3Logo</div>
                            <h1 class="exception-message break-long-words">
                                Whoops, looks like something went wrong.
                                <span id="stacktrace-action-buttons"></span>
                            </h1>
                        </div>
                    </div>
                </div>

                $exceptionInfo

                <div class="container">
                    $content
                </div>
            </div>
HTML;
    }

    /**
     * Renders the HTML for a single throwable.
     */
    protected function getSingleThrowableContent(\Throwable $throwable, int $index, int $total): string
    {
        $exceptionTitle = get_class($throwable);
        $exceptionCode = $throwable->getCode() ? '#' . $throwable->getCode() . ' ' : '';
        $exceptionMessage = $this->escapeHtml($throwable->getMessage());

        // The trace does not contain the step where the exception is thrown.
        // To display it as well it is added manually to the trace.
        $trace = $throwable->getTrace();
        array_unshift($trace, [
            'file' => $throwable->getFile(),
            'line' => $throwable->getLine(),
            'args' => [],
        ]);

        $backtraceCode = $this->getBacktraceCode($trace);

        return <<<HTML
            <div class="trace">
                <div class="trace-head">
                    <h3 class="trace-class">
                        <span class="text-variant">({$index}/{$total})</span>
                        <span class="exception-title">{$exceptionCode}{$exceptionTitle}</span>
                    </h3>
                    <p class="trace-message break-long-words">{$exceptionMessage}</p>
                </div>
                <div class="trace-body">
                    {$backtraceCode}
                </div>
            </div>
HTML;
    }

    /**
     * Generates the stylesheet needed to display the error page.
     */
    protected function getStylesheet(): string
    {
        return <<<STYLESHEET
            html {
                -webkit-text-size-adjust: 100%;
                -ms-text-size-adjust: 100%;
                -ms-overflow-style: scrollbar;
                -webkit-tap-highlight-color: transparent;
            }

            body {
                margin: 0;
            }

            .exception-page {
                background-color: #eaeaea;
                color: #212121;
                font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
                font-weight: 400;
                height: 100dvh;
                line-height: 1.5;
                overflow-x: hidden;
                overflow-y: scroll;
                text-align: left;
                top: 0;
            }

            .panel-collapse .exception-page {
                height: 100%;
            }

            .exception-page a {
                color: #ff8700;
                text-decoration: underline;
            }

            .exception-page a:hover {
                text-decoration: none;
            }

            .exception-page abbr[title] {
                border-bottom: none;
                cursor: help;
                text-decoration: none;
            }

            .exception-page code,
            .exception-page kbd,
            .exception-page pre,
            .exception-page samp {
                font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
                font-size: 1em;
            }

            .exception-page pre {
                background-color: #ffffff;
                overflow-x: auto;
                border: 1px solid rgba(0,0,0,0.125);
            }

            .exception-page pre span {
                display: block;
                line-height: 1.3em;
            }

            .exception-page pre span:before {
                display: inline-block;
                content: attr(data-line);
                border-right: 1px solid #b9b9b9;
                margin-right: 0.5em;
                padding-right: 0.5em;
                background-color: #f4f4f4;
                width: 4em;
                text-align: right;
                color: #515151;
            }

            .exception-page pre span.highlight {
                background-color: #cce5ff;
            }

            .exception-page .break-long-words {
                -ms-word-break: break-all;
                word-break: break-all;
                word-break: break-word;
                -webkit-hyphens: auto;
                -moz-hyphens: auto;
                hyphens: auto;
            }

            .exception-page .callout {
                padding: 1.5rem;
                background-color: #fff;
                margin-bottom: 2em;
                box-shadow: 0 2px 1px rgba(0,0,0,.15);
                border-left: 3px solid #8c8c8c;
            }

            .exception-page .callout-title {
                margin: 0;
            }

            .exception-page .callout-body p:last-child {
                margin-bottom: 0;
            }

            .exception-page .container {
                max-width: 1140px;
                margin: 0 auto;
                padding: 0 30px;
            }

            .panel-collapse .exception-page .container {
                width: 100%;
            }

            .exception-page .exception-illustration {
                width: 3em;
                height: 3em;
                float: left;
                margin-right: 1rem;
            }

            .exception-page .exception-illustration svg {
                width: 100%;
            }

            .exception-page .exception-illustration svg path {
                fill: #ff8700;
            }

            .exception-page .exception-summary {
                background: #000000;
                color: #fff;
                padding: 1.5rem 0;
                margin-bottom: 2rem;
            }

            .exception-page .exception-summary h1 {
                margin: 0;
            }

            .exception-page .text-variant {
                opacity: 0.5;
            }

            .exception-page .trace {
                background-color: #fff;
                margin-bottom: 2rem;
                box-shadow: 0 2px 1px rgba(0,0,0,.15);
            }

            .exception-page .trace-arguments {
                color: #8c8c8c;
            }

            .exception-page .trace-hint {
                margin: 0 0 0.5rem 0;
                text-align: center;
            }

            .exception-page .trace-body {
            }

            .exception-page .trace-call {
                margin-bottom: 1rem;
            }

            .exception-page .trace-class {
                margin: 0;
            }

            .exception-page .trace-file pre {
                margin-top: 1.5rem;
                margin-bottom: 0;
            }

            .exception-page .trace-head {
                color: #721c24;
                background-color: #f8d7da;
                padding: 1.5rem;
            }

            .exception-page .trace-file-path {
                word-break: break-all;
            }

            .exception-page .trace-message {
                margin-bottom: 0;
            }

            .exception-page .trace-step {
                padding: 1.5rem;
                border-bottom: 1px solid #b9b9b9;
            }

            .exception-page .trace-step > *:first-child {
                margin-top: 0;
            }

            .exception-page .trace-step > *:last-child {
                margin-bottom: 0;
            }

            .exception-page .trace-step:nth-child(even)
            {
                background-color: #fafafa;
            }

            .exception-page .trace-step:last-child {
                border-bottom: none;
            }

            .exception-page .copy-button {
                cursor: pointer;
                border: 0.1rem solid transparent;
                background-color: transparent;
                padding: 0;
                margin-left: 1rem;
            }

            .exception-page .copy-button:hover {
                border: 0.1rem solid #b9b9b9;
            }

            .exception-page #stacktrace-action-buttons {
                display: inline-flex;
                justify-content: center;
                gap: 0.5rem;
                margin-top: 1rem;
            }

            .exception-page .stacktrace-action-button {
                cursor: pointer;
                padding: 0.5rem;
                -webkit-text-size-adjust: 100%;
                -webkit-tap-highlight-color: rgba(0,0,0,0);
                box-sizing: border-box;
                background-color: color(srgb 0.97 0.97 0.97);
                border: 1px solid color(srgb 0.75 0.75 0.75);
                border-radius: .75em;
                color: color(srgb 0.1 0.1 0.1);
                display: inline-flex;
                font-weight: 400;
                gap: .35em;
                justify-content: center;
                outline-offset: 0;
                text-decoration: none;
                --typo3-transition-color: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,opacity .15s ease-in-out;
                transition: var(--typo3-transition-color);
                user-select: none;
                vertical-align: middle;
                white-space: nowrap;
                margin-bottom: 0;
                margin-top: 0;
            }

            pre.plaintextFallback {
                margin: 2rem auto;
                border: 1px solid black;
                max-height: 250px;
                font-size: 0.8em;
                padding: 1rem;
            }

STYLESHEET;
    }

    /**
     * Returns JavaScript functionality. Loaded from a TypeScript build.
     * It is loaded inline, to not need to load additional URIs or build routes to assets.
     * Also, it does not use ES6 module loading to be light-weight and dependency free.
     */
    protected function getJavascript(): string
    {
        return file_get_contents(__DIR__ . '/../../Resources/Public/JavaScript/utility/debug-exception-handler-service.js');
    }

    /**
     * Renders the backtrace as HTML.
     */
    protected function getBacktraceCode(array $trace): string
    {
        $content = '';

        foreach ($trace as $step) {
            $content .= '<div class="trace-step">';
            $args = $this->flattenArgs($step['args'] ?? []);

            if (isset($step['function'])) {
                $content .= '<div class="trace-call">' . sprintf(
                    'at <span class="trace-class">%s</span><span class="trace-type">%s</span><span class="trace-method">%s</span>(<span class="trace-arguments">%s</span>)',
                    $step['class'] ?? '',
                    $step['type'] ?? '',
                    $step['function'],
                    $this->formatArgs($args)
                ) . '</div>';
            }

            if (isset($step['file']) && isset($step['line'])) {
                $content .= $this->getCodeSnippet($step['file'], $step['line']);
            }

            $content .= '</div>';
        }

        return $content;
    }

    /**
     * Returns a code snippet from the specified file.
     *
     * @param string $filePathAndName Absolute path and file name of the PHP file
     * @param int $lineNumber Line number defining the center of the code snippet
     * @return string The code snippet
     */
    protected function getCodeSnippet(string $filePathAndName, int $lineNumber): string
    {
        $showLinesAround = 4;

        $content = '<div class="trace-file">';
        $content .= '<div class="trace-file-head">' . $this->formatPath($filePathAndName, $lineNumber) . '</div>';

        if (@file_exists($filePathAndName)) {
            $phpFile = @file($filePathAndName);
            if (is_array($phpFile)) {
                $startLine = $lineNumber > $showLinesAround ? $lineNumber - $showLinesAround : 1;
                $phpFileCount = count($phpFile);
                $endLine = $lineNumber < $phpFileCount - $showLinesAround ? $lineNumber + $showLinesAround + 1 : $phpFileCount + 1;
                if ($endLine > $startLine) {
                    $content .= '<div class="trace-file-content">';
                    $content .= '<pre>';

                    for ($line = $startLine; $line < $endLine; $line++) {
                        $codeLine = str_replace("\t", ' ', $phpFile[$line - 1]);
                        $spanClass = '';
                        if ($line === $lineNumber) {
                            $spanClass = 'highlight';
                        }

                        $content .= '<span class="' . $spanClass . '" data-line="' . $line . '">' . $this->escapeHtml($codeLine) . '</span>';
                    }

                    $content .= '</pre>';
                    $content .= '</div>';
                }
            }
        }

        $content .= '</div>';

        return $content;
    }

    /**
     * Formats a path adding a line number.
     *
     * @param string $path The full path of the file.
     * @param int $line The line number.
     */
    protected function formatPath(string $path, int $line): string
    {
        // "data-lineno" is evaluated by debug-exception-handler-service.js
        return sprintf(
            '<span class="block trace-file-path">in <strong data-lineno="%s">%s</strong>%s</span>',
            $line > 0 ? $line : 1,
            $this->escapeHtml($path),
            $line > 0 ? ' line ' . $line : ''
        );
    }

    /**
     * Formats the arguments of a method call.
     *
     * @param array $args The flattened args of method/function call
     */
    protected function formatArgs(array $args): string
    {
        $result = [];
        foreach ($args as $key => $item) {
            if ($item[0] === 'object') {
                $formattedValue = sprintf('<em>object</em>(%s)', $item[1]);
            } elseif ($item[0] === 'array') {
                $formattedValue = sprintf('<em>array</em>(%s)', is_array($item[1]) ? $this->formatArgs($item[1]) : $item[1]);
            } elseif ($item[0] === 'null') {
                $formattedValue = '<em>null</em>';
            } elseif ($item[0] === 'boolean') {
                $formattedValue = '<em>' . strtolower(var_export($item[1], true)) . '</em>';
            } elseif ($item[0] === 'resource') {
                $formattedValue = '<em>resource</em>';
            } else {
                $formattedValue = str_replace("\n", '', $this->escapeHtml(var_export($item[1], true)));
            }

            $result[] = is_int($key) ? $formattedValue : sprintf("'%s' => %s", $this->escapeHtml($key), $formattedValue);
        }

        return implode(', ', $result);
    }

    protected function flattenArgs(array $args, int $level = 0, int &$count = 0): array
    {
        $result = [];
        foreach ($args as $key => $value) {
            if (++$count > 1e4) {
                return ['array', '*SKIPPED over 10000 entries*'];
            }
            if ($value instanceof \__PHP_Incomplete_Class) {
                // is_object() returns false on PHP<=7.1
                $result[$key] = ['incomplete-object', $this->getClassNameFromIncomplete($value)];
            } elseif (is_object($value)) {
                $result[$key] = ['object', get_class($value)];
            } elseif (is_array($value)) {
                if ($level > 10) {
                    $result[$key] = ['array', '*DEEP NESTED ARRAY*'];
                } else {
                    $result[$key] = ['array', $this->flattenArgs($value, $level + 1, $count)];
                }
            } elseif ($value === null) {
                $result[$key] = ['null', null];
            } elseif (is_bool($value)) {
                $result[$key] = ['boolean', $value];
            } elseif (is_int($value)) {
                $result[$key] = ['integer', $value];
            } elseif (is_float($value)) {
                $result[$key] = ['float', $value];
            } elseif (is_resource($value)) {
                $result[$key] = ['resource', get_resource_type($value)];
            } else {
                $result[$key] = ['string', (string)$value];
            }
        }

        return $result;
    }

    protected function getClassNameFromIncomplete(\__PHP_Incomplete_Class $value): string
    {
        $array = new \ArrayObject($value);

        return $array['__PHP_Incomplete_Class_Name'];
    }

    protected function escapeHtml(string $str): string
    {
        return htmlspecialchars($str, ENT_COMPAT | ENT_SUBSTITUTE);
    }

    protected function getTypo3LogoAsSvg(): string
    {
        return <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M11.1 10.3c-.2 0-.3.1-.5.1C9 10.4 6.8 5 6.8 3.2c0-.7.2-.9.4-1.1-2 .2-4.2.9-4.9 1.8-.2.2-.3.6-.3 1 0 2.8 3 9.2 5.1 9.2 1 0 2.6-1.6 4-3.8m-1-8.4c1.9 0 3.9.3 3.9 1.4 0 2.2-1.4 4.9-2.1 4.9C10.6 8.3 9 4.7 9 2.9c0-.8.3-1 1.1-1"></path></svg>
SVG;
    }

    protected function getAllThrowables(\Throwable $throwable): array
    {
        $all = [$throwable];

        while ($throwable = $throwable->getPrevious()) {
            $all[] = $throwable;
        }

        return $all;
    }
}
