<?php

namespace App\Services\Security;

use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;

/**
 * ITSWEBER CMS – SVG Sanitizer
 *
 * DOMDocument-based whitelist sanitizer for untrusted SVG content
 * (file uploads in UploadService, pasted icons in section inputs).
 *
 * Usage:
 *     $safe = SvgSanitizer::sanitize($userInput);
 *     if ($safe === null) { // reject }
 *
 * Returns the sanitized SVG string (without XML prolog) or null if the
 * input cannot be parsed or contains no <svg> root element.
 *
 * @author    Benny Weber (ITSWEBER)
 * @copyright © ITSWEBER – www.itsweber.de
 */
class SvgSanitizer
{
    private const SVG_NS   = 'http://www.w3.org/2000/svg';
    private const XLINK_NS = 'http://www.w3.org/1999/xlink';

    /**
     * Element local-names allowed in the output (lowercase for comparison;
     * SVG element names are case-insensitive in HTML context, and libxml
     * preserves original casing in XML output regardless of this list).
     * Anything else is removed.
     */
    private const ALLOWED_TAGS = [
        'svg', 'g', 'defs', 'symbol', 'use', 'title', 'desc',
        'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
        'text', 'tspan',
        'marker', 'lineargradient', 'radialgradient', 'stop',
        'pattern', 'mask', 'clippath',
        'filter', 'fegaussianblur', 'feoffset', 'feblend', 'feflood',
        'fecomposite', 'femerge', 'femergenode', 'fecolormatrix',
    ];

    /** Attribute local-names allowed on any element (lowercase for comparison). */
    private const ALLOWED_ATTRS = [
        'xmlns', 'version',
        'viewbox', 'width', 'height',
        'x', 'y', 'x1', 'y1', 'x2', 'y2',
        'cx', 'cy', 'r', 'rx', 'ry',
        'd', 'points',
        'transform', 'preserveaspectratio',
        'fill', 'fill-opacity', 'fill-rule',
        'stroke', 'stroke-width', 'stroke-opacity',
        'stroke-linecap', 'stroke-linejoin',
        'stroke-dasharray', 'stroke-miterlimit',
        'opacity', 'offset', 'stop-color', 'stop-opacity',
        'id', 'class',
        'gradientunits', 'gradienttransform', 'spreadmethod',
        'patternunits', 'maskunits', 'clippathunits', 'filterunits',
        'text-anchor', 'font-family', 'font-size', 'font-weight',
        'letter-spacing', 'dominant-baseline',
        'space',
        // href is handled specially (only local #anchor is accepted)
        'href',
        // filter primitive attributes
        'in', 'in2', 'result', 'stddeviation', 'dx', 'dy',
        'type', 'values', 'mode', 'flood-color', 'flood-opacity',
        'operator', 'k1', 'k2', 'k3', 'k4',
    ];

    public static function sanitize(string $svg): ?string
    {
        $svg = trim($svg);
        if ($svg === '') {
            return null;
        }

        // Pre-parse hardening: strip any DOCTYPE / ENTITY / processing
        // instructions to block XXE before libxml ever sees them.
        $svg = self::stripDangerousDirectives($svg);

        // Ensure we have an <svg> root. Extract it if wrapped in markup.
        if (preg_match('#<svg\b[\s\S]*?</svg\s*>#i', $svg, $m)) {
            $svg = $m[0];
        } else {
            return null;
        }

        $prev = libxml_use_internal_errors(true);

        $doc = new DOMDocument('1.0', 'UTF-8');
        $doc->preserveWhiteSpace = false;
        $doc->formatOutput       = false;

        // LIBXML_NONET: no network fetches (DTDs, external entities).
        // LIBXML_NOENT intentionally NOT set – we don't want entity substitution.
        $loaded = $doc->loadXML(
            '<?xml version="1.0" encoding="UTF-8"?>' . $svg,
            LIBXML_NONET | LIBXML_NOCDATA
        );

        libxml_clear_errors();
        libxml_use_internal_errors($prev);

        if (!$loaded || $doc->documentElement === null) {
            return null;
        }

        $root = $doc->documentElement;
        if (strtolower($root->localName ?? '') !== 'svg') {
            return null;
        }

        self::cleanAttributes($root);
        self::cleanNode($root);

        // Root must still be an <svg> after cleaning.
        if ($doc->documentElement === null
            || strtolower($doc->documentElement->localName ?? '') !== 'svg') {
            return null;
        }

        $out = $doc->saveXML($doc->documentElement);
        if ($out === false) {
            return null;
        }
        return $out;
    }

    /**
     * Remove XML directives, processing instructions and entity declarations
     * that could trigger XXE or stylesheet loading before DOM parsing.
     */
    private static function stripDangerousDirectives(string $svg): string
    {
        $svg = preg_replace('/<!DOCTYPE[^>]*(\[[^\]]*\])?[^>]*>/i', '', $svg) ?? $svg;
        $svg = preg_replace('/<!ENTITY[^>]*>/i', '', $svg) ?? $svg;
        $svg = preg_replace('/<\?xml-stylesheet[^>]*\?>/i', '', $svg) ?? $svg;
        // Strip any non-SVG processing instruction; DOMDocument adds its own prolog.
        $svg = preg_replace('/<\?(?!xml\b)[^>]*\?>/i', '', $svg) ?? $svg;
        return $svg;
    }

    private static function cleanNode(DOMNode $node): void
    {
        // Iterate children on a snapshot because we modify the live list.
        $children = [];
        foreach ($node->childNodes as $child) {
            $children[] = $child;
        }

        foreach ($children as $child) {
            // Strip comments and processing instructions outright.
            if ($child->nodeType === XML_COMMENT_NODE
                || $child->nodeType === XML_PI_NODE) {
                $node->removeChild($child);
                continue;
            }

            if ($child instanceof DOMElement) {
                $localName = strtolower($child->localName ?? '');

                // Namespace lock: only SVG and XLink nodes survive.
                $ns = $child->namespaceURI;
                if ($ns !== null && $ns !== self::SVG_NS && $ns !== self::XLINK_NS) {
                    $node->removeChild($child);
                    continue;
                }

                if (!in_array($localName, self::ALLOWED_TAGS, true)) {
                    $node->removeChild($child);
                    continue;
                }

                self::cleanAttributes($child);
                self::cleanNode($child);
            }
        }
    }

    private static function cleanAttributes(DOMElement $el): void
    {
        $toRemove = [];

        foreach ($el->attributes as $attr) {
            $name      = $attr->nodeName;                     // e.g. "xlink:href"
            $localName = strtolower($attr->localName ?? '');  // e.g. "href"
            $lowerName = strtolower($name);
            $value     = $attr->nodeValue ?? '';

            // Event handlers: drop unconditionally.
            if (str_starts_with($lowerName, 'on')) {
                $toRemove[] = $name;
                continue;
            }

            // Hard-banned attributes regardless of value.
            if (in_array($localName, ['style', 'srcset', 'formaction'], true)) {
                $toRemove[] = $name;
                continue;
            }

            // Namespace lock on attributes.
            $ns = $attr->namespaceURI;
            if ($ns !== null && $ns !== self::SVG_NS && $ns !== self::XLINK_NS) {
                // Keep xmlns declarations, drop everything else foreign.
                if (strtolower($attr->prefix ?? '') !== 'xmlns'
                    && strtolower($name) !== 'xmlns') {
                    $toRemove[] = $name;
                    continue;
                }
            }

            // Whitelist check (by full qualified name OR local name).
            if (!in_array($lowerName, self::ALLOWED_ATTRS, true)
                && !in_array($localName, self::ALLOWED_ATTRS, true)) {
                $toRemove[] = $name;
                continue;
            }

            // href / xlink:href: only local anchor references ("#foo") allowed.
            if ($localName === 'href') {
                $trimmed = ltrim($value);
                if ($trimmed === '' || $trimmed[0] !== '#') {
                    $toRemove[] = $name;
                    continue;
                }
            }

            // Defense-in-depth: reject dangerous URL schemes in any value.
            if (self::hasDangerousScheme($value)) {
                $toRemove[] = $name;
                continue;
            }
        }

        foreach ($toRemove as $name) {
            // removeAttribute works for both prefixed and unprefixed names.
            if ($el->hasAttribute($name)) {
                $el->removeAttribute($name);
            } else {
                // Namespaced fallback (e.g. xlink:href when prefix differs).
                @$el->removeAttributeNS(self::XLINK_NS, 'href');
            }
        }
    }

    private static function hasDangerousScheme(string $value): bool
    {
        $v = strtolower(trim($value));
        // Decode a few common obfuscations before checking.
        $v = str_replace(["\t", "\n", "\r", "\0"], '', $v);
        $v = preg_replace('/\s+/', '', $v) ?? $v;

        if (str_starts_with($v, 'javascript:')) return true;
        if (str_starts_with($v, 'vbscript:'))   return true;
        if (str_starts_with($v, 'data:'))       return true;
        if (str_starts_with($v, 'file:'))       return true;
        return false;
    }
}
