<?php
declare(strict_types=1);

namespace Celena\Core\Security;

/**
 * Минималистичный санитайзер HTML на основе DOMDocument.
 * Использует whitelist тегов и атрибутов, нормализует href/src и режет
 * javascript:/data:/vbscript:-схемы, обработчики событий, <script>, <style>,
 * <iframe>-source за пределами whitelist'a.
 *
 * Назначение: очистка пользовательского HTML (тело новостей, страниц), который
 * затем выводится через {var|raw}. Не претендует на полноту HTML Purifier,
 * но закрывает основные XSS-векторы.
 */
final class HtmlSanitizer
{
    /** @var array<string, list<string>> tag => allowed attributes */
    private const ALLOWED = [
        'p'          => ['class'],
        'br'         => [],
        'hr'         => [],
        'strong'     => [],
        'b'          => [],
        'em'         => [],
        'i'          => [],
        'u'          => [],
        's'          => [],
        'sup'        => [],
        'sub'        => [],
        'h2'         => ['id', 'class'],
        'h3'         => ['id', 'class'],
        'h4'         => ['id', 'class'],
        'h5'         => ['id', 'class'],
        'h6'         => ['id', 'class'],
        'ul'         => ['class'],
        'ol'         => ['class', 'start'],
        'li'         => ['class'],
        'a'          => ['href', 'title', 'target', 'rel', 'class'],
        'img'        => ['src', 'alt', 'title', 'width', 'height', 'class'],
        'figure'     => ['class'],
        'figcaption' => ['class'],
        'blockquote' => ['cite', 'class'],
        'code'       => ['class'],
        'pre'        => ['class'],
        'span'       => ['class'],
        'div'        => ['class'],
        'table'      => ['class'],
        'thead'      => [],
        'tbody'      => [],
        'tr'         => [],
        'th'         => ['scope', 'colspan', 'rowspan'],
        'td'         => ['colspan', 'rowspan'],
        'iframe'     => ['src', 'width', 'height', 'allowfullscreen'],
    ];

    /** Безопасные хосты для iframe (видео-эмбеды). */
    private const IFRAME_HOSTS = [
        'www.youtube.com', 'youtube.com', 'youtube-nocookie.com',
        'player.vimeo.com', 'vimeo.com',
    ];

    /**
     * Теги вне whitelist, которые удаляются ВМЕСТЕ с содержимым (а не «разворачиваются»).
     * Их текст/дети не должны всплывать в вывод (исполняемый код, формы, вложенные документы).
     */
    private const STRIP_WITH_CONTENT = [
        'script', 'style', 'template', 'noscript', 'svg', 'math',
        'object', 'embed', 'applet', 'form', 'button', 'input',
        'textarea', 'select', 'option', 'link', 'meta', 'base', 'title',
    ];

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

        // Убираем нулевые байты и BOM
        $html = str_replace(["\0", "\xEF\xBB\xBF"], '', $html);

        $doc = new \DOMDocument('1.0', 'UTF-8');
        // Оборачиваем в html-структуру и подсказываем кодировку через meta — иначе
        // libxml считает поток как ISO-8859-1 и ломает кириллицу.
        $wrapped = '<?xml encoding="UTF-8"?><!DOCTYPE html><html><head>'
                 . '<meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>'
                 . '<body>' . $html . '</body></html>';
        $libxmlPrev = libxml_use_internal_errors(true);
        $doc->loadHTML($wrapped, LIBXML_NONET | LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED);
        libxml_clear_errors();
        libxml_use_internal_errors($libxmlPrev);

        $body = $doc->getElementsByTagName('body')->item(0);
        if (!$body instanceof \DOMElement) {
            return '';
        }

        self::cleanNode($body);

        $out = '';
        foreach ($body->childNodes as $child) {
            $out .= $doc->saveHTML($child);
        }
        return trim($out);
    }

    private static function cleanNode(\DOMNode $node): void
    {
        // Идём по копии, потому что удаление меняет коллекцию.
        $children = [];
        foreach ($node->childNodes as $child) {
            $children[] = $child;
        }
        foreach ($children as $child) {
            if ($child instanceof \DOMElement) {
                $tag = strtolower($child->nodeName);
                if (!isset(self::ALLOWED[$tag])) {
                    // Опасные обёртки удаляем целиком, вместе с содержимым.
                    if (in_array($tag, self::STRIP_WITH_CONTENT, true)) {
                        $node->removeChild($child);
                        continue;
                    }
                    // Прочие неразрешённые теги «разворачиваем»: СНАЧАЛА рекурсивно
                    // чистим поддерево (иначе поднятые наверх дети минуют фильтр
                    // атрибутов — это был вектор хранимого XSS), затем поднимаем
                    // уже безопасных детей и удаляем сам узел-обёртку.
                    self::cleanNode($child);
                    while ($child->firstChild) {
                        $node->insertBefore($child->firstChild, $child);
                    }
                    $node->removeChild($child);
                    continue;
                }
                self::filterAttributes($child, $tag);
                self::cleanNode($child);
            } elseif ($child instanceof \DOMComment) {
                // Условные комментарии IE могут протащить выполнение в старых браузерах.
                $node->removeChild($child);
            } elseif ($child instanceof \DOMProcessingInstruction) {
                $node->removeChild($child);
            }
            // Текстовые узлы, CDATA — оставляем как есть.
        }
    }

    private static function filterAttributes(\DOMElement $el, string $tag): void
    {
        $allowedAttrs = self::ALLOWED[$tag];
        // Снимаем атрибуты в обратном порядке, чтобы безопасно удалять.
        $names = [];
        foreach ($el->attributes as $attr) {
            $names[] = $attr->nodeName;
        }
        foreach ($names as $name) {
            $lower = strtolower($name);
            if (!in_array($lower, $allowedAttrs, true)) {
                $el->removeAttribute($name);
                continue;
            }
            $value = (string) $el->getAttribute($name);
            // Дополнительная проверка href/src на безопасные схемы.
            if ($lower === 'href') {
                if (!self::isSafeUrl($value, allowFragment: true, allowMail: true)) {
                    $el->removeAttribute($name);
                    continue;
                }
                // Ссылки во внешний мир — добавляем noopener.
                if ($el->hasAttribute('target') && $el->getAttribute('target') === '_blank') {
                    $el->setAttribute('rel', 'noopener noreferrer');
                }
            } elseif ($lower === 'src') {
                if ($tag === 'iframe') {
                    if (!self::isSafeIframeSrc($value)) {
                        $el->removeAttribute($name);
                        continue;
                    }
                } elseif (!self::isSafeUrl($value, allowFragment: false, allowMail: false, allowData: $tag === 'img')) {
                    $el->removeAttribute($name);
                    continue;
                }
            } elseif ($lower === 'target') {
                if (!in_array(strtolower($value), ['_blank', '_self'], true)) {
                    $el->removeAttribute($name);
                }
            }
        }
    }

    private static function isSafeUrl(string $url, bool $allowFragment, bool $allowMail, bool $allowData = false): bool
    {
        $url = trim($url);
        if ($url === '') return false;
        if ($allowFragment && str_starts_with($url, '#')) return true;
        // относительные пути — разрешены
        if ($url[0] === '/' || $url[0] === '.') return true;
        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
        if ($scheme === 'http' || $scheme === 'https') return true;
        if ($allowMail && $scheme === 'mailto') return true;
        if ($allowData && $scheme === 'data') {
            // только image/* data:
            return (bool) preg_match('#^data:image/(png|jpe?g|gif|webp);base64,#i', $url);
        }
        return false;
    }

    private static function isSafeIframeSrc(string $url): bool
    {
        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
        if ($scheme !== 'https') return false;
        $host = strtolower((string) parse_url($url, PHP_URL_HOST));
        return in_array($host, self::IFRAME_HOSTS, true);
    }
}
