<?php
declare(strict_types=1);

namespace App\Security;

use DOMDocument;
use DOMElement;
use DOMNode;
use InvalidArgumentException;

final class HtmlSanitizer
{
    private const MAX_LENGTH = 20000;

    /** @var array<string, true> */
    private const ALLOWED_TAGS = [
        'a' => true,
        'b' => true,
        'blockquote' => true,
        'br' => true,
        'code' => true,
        'em' => true,
        'h2' => true,
        'h3' => true,
        'i' => true,
        'li' => true,
        'ol' => true,
        'p' => true,
        'pre' => true,
        'span' => true,
        'strong' => true,
        'u' => true,
        'ul' => true,
    ];

    /** @var array<string, true> */
    private const GLOBAL_ATTRIBUTES = [
        'dir' => true,
        'lang' => true,
        'title' => true,
    ];

    public function sanitize(string $html): string
    {
        if ($html === '') {
            return '';
        }

        if (strlen($html) > self::MAX_LENGTH || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $html) === 1) {
            throw new InvalidArgumentException('HTML content is too large or contains unsafe control characters.');
        }

        if (preg_match('/<!--|<!doctype|<!\[cdata|<\?xml/i', $html) === 1) {
            throw new InvalidArgumentException('HTML comments and document declarations are not allowed.');
        }

        if (class_exists(DOMDocument::class)) {
            return $this->sanitizeWithDom($html);
        }

        return $this->sanitizeWithoutDom($html);
    }

    private function sanitizeWithDom(string $html): string
    {
        $document = new DOMDocument('1.0', 'UTF-8');
        $previous = libxml_use_internal_errors(true);

        try {
            $loaded = $document->loadHTML(
                '<?xml encoding="UTF-8"><div id="quaeriva-html-root">' . $html . '</div>',
                LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET
            );

            if ($loaded !== true) {
                throw new InvalidArgumentException('HTML content could not be parsed safely.');
            }

            $root = $document->getElementById('quaeriva-html-root');

            if (!$root instanceof DOMElement) {
                throw new InvalidArgumentException('HTML content could not be parsed safely.');
            }

            $body = $document->getElementsByTagName('body')->item(0);

            if ($body instanceof DOMElement) {
                foreach ($body->childNodes as $child) {
                    if ($child instanceof DOMElement && $child !== $root) {
                        throw new InvalidArgumentException('HTML content contains an unsafe outer element.');
                    }
                }
            }

            foreach ($root->childNodes as $child) {
                $this->validateNode($child);
            }

            $output = '';

            foreach ($root->childNodes as $child) {
                $output .= $document->saveHTML($child);
            }

            return $output;
        } finally {
            libxml_clear_errors();
            libxml_use_internal_errors($previous);
        }
    }

    private function validateNode(DOMNode $node): void
    {
        if ($node->nodeType === XML_TEXT_NODE) {
            return;
        }

        if ($node->nodeType !== XML_ELEMENT_NODE || !$node instanceof DOMElement) {
            throw new InvalidArgumentException('Only safe HTML elements and text are allowed.');
        }

        $tag = strtolower($node->tagName);

        if (!isset(self::ALLOWED_TAGS[$tag])) {
            throw new InvalidArgumentException(sprintf('The HTML tag <%s> is not allowed.', $tag));
        }

        foreach ($node->attributes as $attribute) {
            $name = strtolower($attribute->name);

            if (str_starts_with($name, 'on') || $name === 'style' || $name === 'src' || $name === 'srcset') {
                throw new InvalidArgumentException(sprintf('The HTML attribute %s is not allowed.', $name));
            }

            if ($name === 'href') {
                if ($tag !== 'a') {
                    throw new InvalidArgumentException('The href attribute is only allowed on links.');
                }

                $node->setAttribute('href', $this->validateHref($attribute->value));

                continue;
            }

            if (!isset(self::GLOBAL_ATTRIBUTES[$name])) {
                throw new InvalidArgumentException(sprintf('The HTML attribute %s is not allowed.', $name));
            }

            $node->setAttribute($name, $this->validateAttributeValue($name, $attribute->value));
        }

        foreach ($node->childNodes as $child) {
            $this->validateNode($child);
        }
    }

    private function validateAttributeValue(string $name, string $value): string
    {
        $value = trim($value);

        if ($name === 'dir' && !in_array(strtolower($value), ['auto', 'ltr', 'rtl'], true)) {
            throw new InvalidArgumentException('Only auto, ltr, and rtl are allowed for dir.');
        }

        if ($name === 'lang' && preg_match('/^[a-z]{2,3}(?:-[a-z]{2,8})?$/i', $value) !== 1) {
            throw new InvalidArgumentException('The lang attribute is invalid.');
        }

        if ($name === 'title' && (function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value)) > 200) {
            throw new InvalidArgumentException('The title attribute is too long.');
        }

        return $value;
    }

    private function validateHref(string $href): string
    {
        $href = trim($href);
        $normalized = preg_replace('/[\x00-\x20\x7F]+/u', '', $href) ?? '';

        if ($normalized === '' || str_starts_with($normalized, '//')) {
            throw new InvalidArgumentException('The link URL is not allowed.');
        }

        if (preg_match('/^(?:javascript|data|vbscript):/i', $normalized) === 1) {
            throw new InvalidArgumentException('The link URL scheme is not allowed.');
        }

        if (preg_match('/^https?:\/\//i', $normalized) === 1 || preg_match('/^mailto:[^\s]+$/i', $normalized) === 1) {
            return $href;
        }

        if (preg_match('/^(?:\/|#|\?|\.\/|\.\.\/)[^\s]*$/u', $href) === 1) {
            return $href;
        }

        throw new InvalidArgumentException('Only http(s), mailto, and relative link URLs are allowed.');
    }

    private function sanitizeWithoutDom(string $html): string
    {
        $output = '';
        $offset = 0;
        $stack = [];
        $length = strlen($html);

        while ($offset < $length) {
            $tagOffset = strpos($html, '<', $offset);

            if ($tagOffset === false) {
                $output .= substr($html, $offset);
                break;
            }

            $output .= substr($html, $offset, $tagOffset - $offset);
            $end = strpos($html, '>', $tagOffset);

            if ($end === false) {
                throw new InvalidArgumentException('HTML content contains an incomplete tag.');
            }

            $token = substr($html, $tagOffset, $end - $tagOffset + 1);
            $parsed = $this->parseFallbackTag($token);
            $tag = $parsed['tag'];

            if ($parsed['closing']) {
                if ($stack === [] || array_pop($stack) !== $tag) {
                    throw new InvalidArgumentException('HTML tags are not balanced.');
                }

                $output .= '</' . $tag . '>';
            } else {
                $output .= '<' . $tag . $parsed['attributes'] . '>';

                if (!$parsed['void']) {
                    $stack[] = $tag;
                }
            }

            $offset = $end + 1;
        }

        if ($stack !== []) {
            throw new InvalidArgumentException('HTML tags are not balanced.');
        }

        return $output;
    }

    /** @return array{tag: string, closing: bool, void: bool, attributes: string} */
    private function parseFallbackTag(string $token): array
    {
        if (preg_match('/^<\s*(\/?)\s*([a-z][a-z0-9]*)\b(.*?)>$/is', $token, $matches) !== 1) {
            throw new InvalidArgumentException('HTML contains an unsafe tag.');
        }

        $closing = $matches[1] === '/';
        $tag = strtolower($matches[2]);

        if (!isset(self::ALLOWED_TAGS[$tag])) {
            throw new InvalidArgumentException(sprintf('The HTML tag <%s> is not allowed.', $tag));
        }

        $attributes = '';

        if ($closing) {
            if (trim($matches[3]) !== '') {
                throw new InvalidArgumentException('Closing tags cannot contain attributes.');
            }

            return ['tag' => $tag, 'closing' => true, 'void' => false, 'attributes' => ''];
        }

        $rawAttributes = preg_replace('/\/\s*$/', '', $matches[3]) ?? $matches[3];
        $cursor = 0;

        while (trim(substr($rawAttributes, $cursor)) !== '') {
            if (preg_match('/\G\s+([a-z_:][a-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'`=<>]+))/Ai', $rawAttributes, $attribute, 0, $cursor) !== 1) {
                throw new InvalidArgumentException('HTML contains an unsafe attribute.');
            }

            $name = strtolower($attribute[1]);
            $value = $attribute[2] !== '' ? $attribute[2] : ($attribute[3] !== '' ? $attribute[3] : $attribute[4]);

            if (str_starts_with($name, 'on') || $name === 'style' || $name === 'src' || $name === 'srcset') {
                throw new InvalidArgumentException(sprintf('The HTML attribute %s is not allowed.', $name));
            }

            if ($name === 'href') {
                if ($tag !== 'a') {
                    throw new InvalidArgumentException('The href attribute is only allowed on links.');
                }

                $value = $this->validateHref($value);
            } elseif (!isset(self::GLOBAL_ATTRIBUTES[$name])) {
                throw new InvalidArgumentException(sprintf('The HTML attribute %s is not allowed.', $name));
            } else {
                $value = $this->validateAttributeValue($name, $value);
            }

            $attributes .= ' ' . $name . '="' . htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8') . '"';
            $cursor += strlen($attribute[0]);
        }

        return [
            'tag' => $tag,
            'closing' => false,
            'void' => $tag === 'br',
            'attributes' => $attributes,
        ];
    }
}
