<?php

declare(strict_types=1);

namespace PpPhp\Core\Html;

/**
 * Allowlist HTML sanitiser for user-generated content (Q&A posts, comments,
 * any rich text a visitor can submit). This is a SECURITY BOUNDARY — treat it
 * with the same care as auth code.
 *
 * DOM-based, not regex: it parses the HTML into a tree, walks every node, and
 * keeps ONLY an explicit allowlist of tags and attributes, with strict URL-
 * scheme checks. Everything else is dropped (dangerous containers) or unwrapped
 * to its text (benign unknowns). Regex "sanitisers" are notoriously bypassable;
 * a tree walk with an allowlist is the industry-standard safe approach.
 *
 * Design guarantees:
 *   • No <script>/<style>/<iframe>/<object>/<embed>/<form>/<svg>/<math>/… ever.
 *   • No `on*` handlers, no inline `style`, no HTML comments.
 *   • URLs limited to http/https/mailto (+ relative/anchor); no javascript:,
 *     data:, vbscript:, or whitespace/entity-obfuscated schemes.
 *   • Every surviving link is forced to rel="nofollow ugc noopener" target=_blank
 *     — protects the host's SEO trust and removes the spam incentive.
 *
 * Pair with \PpPhp\Core\Html\Markdown, which already escapes all raw HTML; this
 * class is the second, independent layer of defence.
 */
final class Sanitizer
{
    /** tag => allowed attribute names (URL/rel/numeric handled specially). */
    private const ALLOWED = [
        'p' => [], 'br' => [], 'hr' => [],
        'h2' => [], 'h3' => [], 'h4' => [],
        'strong' => [], 'em' => [], 'b' => [], 'i' => [], 'u' => [], 's' => [], 'del' => [], 'ins' => [],
        'code' => ['class'], 'pre' => ['class'],
        'blockquote' => [],
        'ul' => [], 'ol' => ['start'], 'li' => [],
        'a' => ['href', 'title'],
        'img' => ['src', 'alt', 'title', 'width', 'height'],
        'table' => [], 'thead' => [], 'tbody' => [], 'tr' => [],
        'th' => ['colspan', 'rowspan'], 'td' => ['colspan', 'rowspan'],
        'sup' => [], 'sub' => [], 'kbd' => [],
    ];

    /** Elements dropped ENTIRELY (with their content) — never unwrapped. */
    private const DANGEROUS = [
        'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button',
        'textarea', 'select', 'option', 'svg', 'math', 'link', 'meta', 'base',
        'template', 'noscript', 'title', 'head', 'frame', 'frameset', 'applet',
        'audio', 'video', 'source', 'track', 'canvas', 'map', 'area',
    ];

    private const URL_ATTR = ['href' => true, 'src' => true];
    private const NUMERIC_ATTR = ['width' => true, 'height' => true, 'colspan' => true, 'rowspan' => true, 'start' => true];
    private const ALLOWED_SCHEMES = ['http', 'https', 'mailto'];

    /** Sanitise a fragment of HTML, returning safe HTML (may be empty). */
    public static function clean(string $html): string
    {
        if (trim($html) === '') {
            return '';
        }

        $doc = new \DOMDocument();
        $prev = libxml_use_internal_errors(true);
        // The XML encoding prolog forces UTF-8 parsing; the body wrapper keeps a
        // single, predictable root. LIBXML_NONET forbids any network fetches.
        $doc->loadHTML(
            '<?xml encoding="UTF-8"?><html><body>' . $html . '</body></html>',
            LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET
        );
        libxml_clear_errors();
        libxml_use_internal_errors($prev);

        $body = $doc->getElementsByTagName('body')->item(0);
        if ($body === null) {
            return '';
        }

        self::walk($body);

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

    /** Recursively scrub a node's children in place. */
    private static function walk(\DOMNode $node): void
    {
        // Snapshot children first — we mutate the live list as we go.
        foreach (iterator_to_array($node->childNodes) as $child) {
            if ($child instanceof \DOMText) {
                continue;                                   // text is inert; re-encoded on serialise
            }
            if (!($child instanceof \DOMElement)) {
                $node->removeChild($child);                 // comments, PIs, CDATA → gone
                continue;
            }

            $tag = strtolower($child->localName ?? $child->tagName);

            if (!isset(self::ALLOWED[$tag])) {
                if (in_array($tag, self::DANGEROUS, true)) {
                    $node->removeChild($child);             // drop element AND its content
                } else {
                    self::walk($child);                     // sanitise subtree, then unwrap
                    while ($child->firstChild) {
                        $node->insertBefore($child->firstChild, $child);
                    }
                    $node->removeChild($child);
                }
                continue;
            }

            self::scrubAttributes($child, $tag);

            // Links/images that ended up without a usable URL are removed/unwrapped.
            if ($tag === 'a' && $child->getAttribute('href') === '') {
                self::walk($child);
                while ($child->firstChild) {
                    $node->insertBefore($child->firstChild, $child);
                }
                $node->removeChild($child);
                continue;
            }
            if ($tag === 'img' && $child->getAttribute('src') === '') {
                $node->removeChild($child);
                continue;
            }

            self::walk($child);
        }
    }

    private static function scrubAttributes(\DOMElement $el, string $tag): void
    {
        $allowed = self::ALLOWED[$tag];
        foreach (iterator_to_array($el->attributes) as $attr) {
            $name = strtolower($attr->name);

            if (str_starts_with($name, 'on') || $name === 'style' || !in_array($name, $allowed, true)) {
                $el->removeAttribute($attr->name);
                continue;
            }
            if (isset(self::URL_ATTR[$name])) {
                $safe = self::safeUrl($attr->value, $name === 'src');
                if ($safe === null) {
                    $el->removeAttribute($attr->name);
                } else {
                    $el->setAttribute($attr->name, $safe);
                }
                continue;
            }
            if (isset(self::NUMERIC_ATTR[$name])) {
                if (!ctype_digit(trim($attr->value))) {
                    $el->removeAttribute($attr->name);
                }
                continue;
            }
            if ($name === 'class') {
                // Only code-highlighting hints survive, e.g. class="language-php".
                $keep = array_filter(
                    preg_split('/\s+/', trim($attr->value)) ?: [],
                    static fn (string $c): bool => (bool) preg_match('/^language-[A-Za-z0-9#+.\-]{1,24}$/', $c)
                );
                if ($keep) {
                    $el->setAttribute('class', implode(' ', $keep));
                } else {
                    $el->removeAttribute('class');
                }
            }
        }

        // Force safe link semantics on every surviving anchor.
        if ($tag === 'a' && $el->getAttribute('href') !== '') {
            $el->setAttribute('rel', 'nofollow ugc noopener');
            $el->setAttribute('target', '_blank');
        }
    }

    /**
     * PUBLIC single-URL validator for plugins/callers that build their own markup
     * (Hyperlink, Audio, Video…): returns a safe URL or null to reject. Blocks
     * javascript:, data:, vbscript: and obfuscated schemes; allows http/https/
     * mailto and scheme-less relative/anchor URLs. Pass $isImage=true for src.
     */
    public static function url(string $url, bool $isImage = false): ?string
    {
        return self::safeUrl($url, $isImage);
    }

    /**
     * Return a safe URL, or null to strip the attribute. Blocks javascript:,
     * data:, vbscript: and any scheme obfuscated with whitespace/control chars
     * or HTML entities (DOMDocument has already decoded entities in the value).
     */
    private static function safeUrl(string $url, bool $isImage): ?string
    {
        // Remove all control chars AND whitespace anywhere — "java\tscript:",
        // "  javascript:", "java\nscript:" all collapse so the scheme is visible.
        $stripped = preg_replace('/[\x00-\x20\x7f]+/', '', $url) ?? $url;
        if ($stripped === '') {
            return null;
        }
        if (preg_match('#^([a-z][a-z0-9+.\-]*):#i', $stripped, $m)) {
            $scheme = strtolower($m[1]);
            if (!in_array($scheme, self::ALLOWED_SCHEMES, true)) {
                return null;
            }
            if ($isImage && $scheme === 'mailto') {
                return null;
            }
        }
        // No scheme → relative path or #anchor → allowed. Return the whitespace-
        // stripped form so nothing hidden survives.
        return $stripped;
    }
}
