<?php

namespace App\Support;

use DOMDocument;
use DOMElement;
use Illuminate\Support\Facades\Log;

/**
 * A DOMDocument round trip that gives back the page it was handed.
 *
 * Five services parse a landing page, add something, and save it: the public
 * prompt-landing-page document, the three catalog documents, and the pixel
 * injector. Each wrote the same eleven lines, and each corrupted the page it
 * published in the same three ways. Confirmed on this PHP, not assumed:
 *
 * Script bodies lose anything after a `</`. `var t = "</div>";` is saved as
 * `var t = "";` - not truncated, emptied, with the rest of the script intact
 * below it. Any template string, any regex over a closing tag. It is the worst of
 * the three because the page still loads and the script still runs, wrongly.
 *
 * SVG is lowercased, and SVG is case sensitive. `<linearGradient>` becomes
 * `<lineargradient>`, which is not an element, so `fill="url(#g)"` resolves to
 * nothing and the shape renders transparent. `viewBox` becomes `viewbox` and is
 * ignored, so the drawing is sized wrong. `gradientTransform`,
 * `preserveAspectRatio` and the whole `fe*` filter family go the same way.
 *
 * And none of it was visible. libxml reported five errors on the gradient case
 * and one on the script case; both were cleared without being read, `loadHTML`
 * returned true in every case tried, so the `if (! $loaded)` guard never fired.
 * `libxml_use_internal_errors(true)` was also never restored, so the setting
 * leaked into whatever ran next in the request.
 *
 * Script and style bodies are therefore held out of the parser entirely, by
 * substitution, and put back after saving - which also covers scripts added while
 * the document is open, since those are swapped out at save time. The SVG names
 * are restored from a list, because the parser has already thrown the case away
 * by the time anything can be done about it.
 */
final class HtmlDocument
{
    /** @var array<string, string> */
    private array $raw = [];

    private function __construct(
        private readonly DOMDocument $dom,
        private readonly string $original,
    ) {}

    /**
     * Parse a page, or null when it cannot be parsed at all.
     *
     * The caller returns its input untouched in that case, which is the existing
     * behaviour and the right one: a page we cannot read is a page we should not
     * rewrite.
     */
    public static function from(string $html, string $context = 'html'): ?self
    {
        $document = new self(new DOMDocument, $html);
        $document->dom->preserveWhiteSpace = true;

        $previous = libxml_use_internal_errors(true);

        $loaded = $document->dom->loadHTML($document->withoutRawText($html), LIBXML_HTML_NODEFDTD);

        // Read rather than cleared. These are how a mangled page announces
        // itself, and every one of them used to be thrown away.
        foreach (libxml_get_errors() as $error) {
            if ($error->level === LIBXML_ERR_FATAL) {
                Log::warning('HTML could not be parsed cleanly.', [
                    'context' => $context,
                    'line' => $error->line,
                    'message' => trim($error->message),
                ]);
            }
        }

        libxml_clear_errors();
        libxml_use_internal_errors($previous);

        return $loaded ? $document : null;
    }

    public function dom(): DOMDocument
    {
        return $this->dom;
    }

    /** The document as HTML, or the original when it cannot be serialised. */
    public function html(): string
    {
        // Anything added while the document was open is swapped out now, so an
        // injected pixel or capture script is protected on the same terms as the
        // page's own scripts.
        $this->holdOutScriptNodes();

        $output = $this->dom->saveHTML();

        if (! is_string($output)) {
            return $this->original;
        }

        return $this->restoreSvgNames($this->withRawText($output));
    }

    /**
     * Replace script and style bodies with tokens before the parser sees them.
     *
     * A token of letters, digits and underscores only, so nothing downstream -
     * the parser, the serialiser, entity escaping - can alter it.
     */
    private function withoutRawText(string $html): string
    {
        return (string) preg_replace_callback(
            '#(<(script|style)\b[^>]*>)(.*?)(</\2\s*>)#is',
            function (array $matches): string {
                if (trim($matches[3]) === '') {
                    return $matches[0];
                }

                $token = $this->hold($matches[3]);

                return $matches[1].$token.$matches[4];
            },
            $html,
        );
    }

    /** Swap the text of any script or style node that is not already held. */
    private function holdOutScriptNodes(): void
    {
        foreach (['script', 'style'] as $tag) {
            foreach (iterator_to_array($this->dom->getElementsByTagName($tag)) as $node) {
                if (! $node instanceof DOMElement) {
                    continue;
                }

                $text = $node->textContent;

                if (trim($text) === '' || isset($this->raw[trim($text)])) {
                    continue;
                }

                $node->textContent = $this->hold($text);
            }
        }
    }

    private function hold(string $text): string
    {
        $token = sprintf('__ARB_RAW_%s__', hash('xxh3', $text.count($this->raw)));
        $this->raw[$token] = $text;

        return $token;
    }

    private function withRawText(string $html): string
    {
        return $this->raw === [] ? $html : str_replace(array_keys($this->raw), array_values($this->raw), $html);
    }

    /**
     * Put the case back into SVG's own names.
     *
     * A list rather than a rule, because there is no rule: these are simply the
     * names SVG spells with capitals, and the HTML parser lowercases every one.
     * Restoring them is safe outside an <svg> too - none of these is a valid HTML
     * element or attribute, so a lowercase occurrence can only have come from
     * here.
     */
    private function restoreSvgNames(string $html): string
    {
        static $names = [
            // Elements.
            'linearGradient', 'radialGradient', 'clipPath', 'foreignObject', 'textPath',
            'animateMotion', 'animateTransform', 'feBlend', 'feColorMatrix',
            'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting',
            'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA',
            'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge',
            'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting',
            'feSpotLight', 'feTile', 'feTurbulence',
            // Attributes.
            'viewBox', 'preserveAspectRatio', 'gradientTransform', 'gradientUnits',
            'patternTransform', 'patternUnits', 'patternContentUnits', 'clipPathUnits',
            'maskUnits', 'maskContentUnits', 'markerWidth', 'markerHeight', 'markerUnits',
            'refX', 'refY', 'spreadMethod', 'startOffset', 'stdDeviation', 'baseFrequency',
            'numOctaves', 'stitchTiles', 'surfaceScale', 'specularConstant',
            'specularExponent', 'diffuseConstant', 'kernelMatrix', 'kernelUnitLength',
            'tableValues', 'xChannelSelector', 'yChannelSelector', 'primitiveUnits',
            'filterUnits', 'textLength', 'lengthAdjust', 'pathLength', 'attributeName',
            'attributeType', 'repeatCount', 'repeatDur', 'calcMode', 'keyTimes',
            'keySplines', 'keyPoints', 'requiredExtensions', 'requiredFeatures',
            'systemLanguage', 'baseProfile', 'zoomAndPan', 'externalResourcesRequired',
            'limitingConeAngle', 'pointsAtX', 'pointsAtY', 'pointsAtZ',
        ];

        static $pattern = null;
        static $replace = null;

        if ($pattern === null) {
            foreach ($names as $name) {
                $lower = strtolower($name);

                // Tag positions and attribute positions, so a word in prose that
                // happens to match is left alone.
                $pattern[] = '#(</?)'.$lower.'(?=[\s/>])#';
                $replace[] = '$1'.$name;
                $pattern[] = '#(\s)'.$lower.'(?==)#';
                $replace[] = '$1'.$name;
            }
        }

        return (string) preg_replace($pattern, $replace, $html);
    }
}
