<?php

namespace App\Services;

use App\Services\LegacySmileyMapper;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\MarkdownConverter;

/**
 * Sanitizes and renders user-submitted content.
 *
 * Pipeline:
 *  1. Strip any raw HTML tags from input (we don't allow HTML)
 *  2. Convert legacy <br> / <b> / <i> hints from really old legacy content
 *  3. Parse subset of Markdown (bold, italic, code, links, line breaks)
 *  4. Sanitize the rendered HTML: whitelist-only tags, strip attributes
 *  5. Return safe HTML ready for storage or display
 */
class ContentSanitizer
{
    /** Maximum number of emoji allowed before triggering a flood error. */
    public const EMOJI_COUNT_MAX = 50;

    /**
     * Maximum ratio of emoji-to-total-characters before content is considered
     * an emoji flood (applies only when emoji count > 5 to avoid false positives
     * on very short strings like a single reaction comment).
     */
    public const EMOJI_DENSITY_MAX = 0.40;

    // HTML tags we allow in the final rendered output
    // Include heading tags so editor-produced headings (h1-h6) are preserved.
    private const ALLOWED_TAGS = [
        'p', 'br', 'strong', 'em', 'code', 'pre',
        'a', 'ul', 'ol', 'li', 'blockquote', 'del',
        'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        // Image and embed-related tags used by the rich editor
        'figure', 'figcaption', 'img', 'picture', 'source', 'iframe',
        // Basic structural/inline helpers sometimes produced by embeds
        'div', 'span'
    ];

    // Allowed attributes per tag
    private const ALLOWED_ATTRS = [
        'a' => ['href', 'title', 'rel', 'target'],
        'img' => ['src', 'srcset', 'sizes', 'alt', 'title', 'loading', 'decoding', 'width', 'height', 'style', 'class', 'data-width'],
        'source' => ['srcset', 'src', 'type', 'media', 'sizes'],
        'figure' => ['class', 'data-rich-image', 'data-platform', 'data-video-embed', 'data-social-embed', 'data-artwork-embed'],
        'figcaption' => ['class'],
        'iframe' => ['src', 'title', 'loading', 'frameborder', 'allow', 'allowfullscreen', 'referrerpolicy'],
        'div' => ['class', 'data-href', 'data-show-text'],
        'span' => ['class'],
    ];

    private static ?MarkdownConverter $converter = null;

    // ─────────────────────────────────────────────────────────────────────────
    // Public API
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Convert raw user input (legacy or new) to sanitized HTML.
     *
     * @param string|null $raw
     * @return string  Safe HTML
     */
    public static function render(?string $raw): string
    {
        if ($raw === null || trim($raw) === '') {
            return '';
        }

        // 1. Convert legacy HTML fragments to Markdown-friendly text
        $text = static::legacyHtmlToMarkdown($raw);

        // 2. Parse Markdown → HTML
        $html = static::parseMarkdown($text);

        // 3. Sanitize HTML (strip disallowed tags / attrs)
        $html = static::sanitizeHtml($html);

        return $html;
    }

    /**
     * Normalize previously rendered HTML for display-time policy changes.
     * This is useful when stored HTML predates current link attributes or
     * when display rules depend on the author rather than the raw content.
     */
    public static function sanitizeRenderedHtml(?string $html, bool $allowLinks = true): string
    {
        if ($html === null || trim($html) === '') {
            return '';
        }

        return static::sanitizeHtml($html, $allowLinks);
    }

    /**
     * Strip ALL HTML from input, returning plain text with newlines preserved.
     */
    public static function stripToPlain(?string $html): string
    {
        if ($html === null) {
            return '';
        }

        // Convert <br> and <p> to line breaks before stripping
        $text = preg_replace(['/<br\s*\/?>/i', '/<\/p>/i'], "\n", $html);
        $text = strip_tags($text ?? '');
        $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');

        return trim($text);
    }

    /**
     * Validate that a Markdown-lite string does not contain disallowed patterns.
     * Returns an array of validation errors (empty = OK).
     */
    public static function validate(string $raw): array
    {
        $errors = [];

        if (mb_strlen($raw) > 10_000) {
            $errors[] = 'Content exceeds maximum length of 10,000 characters.';
        }

        // Detect raw HTML tags (we forbid them)
        if (preg_match('/<[a-z][^>]*>/i', $raw)) {
            $errors[] = 'HTML tags are not allowed. Use Markdown formatting instead.';
        }

        // Count emoji to prevent absolute spam
        $emojiCount = static::countEmoji($raw);
        if ($emojiCount > self::EMOJI_COUNT_MAX) {
            $errors[] = 'Too many emoji. Please limit emoji usage.';
        }

        // Reject emoji-flood content: density guard catches e.g. 15 emoji in a
        // 20-char string even when the absolute count is below EMOJI_COUNT_MAX.
        if ($emojiCount > 5) {
            $totalChars = mb_strlen($raw);
            if ($totalChars > 0 && ($emojiCount / $totalChars) > self::EMOJI_DENSITY_MAX) {
                $errors[] = 'Content is mostly emoji. Please add some text.';
            }
        }

        return $errors;
    }

    /**
     * Collapse consecutive runs of the same emoji in $text.
     *
     * Delegates to LegacySmileyMapper::collapseFlood() so the behaviour is
     * consistent between new submissions and migrated legacy content.
     *
     * Example: "🍺 🍺 🍺 🍺 🍺 🍺 🍺" (7×) → "🍺 🍺 🍺 🍺 🍺 ×7"
     *
     * @param  int $maxRun  Keep at most this many consecutive identical emoji.
     */
    public static function collapseFlood(string $text, int $maxRun = 5): string
    {
        return LegacySmileyMapper::collapseFlood($text, $maxRun);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Private helpers
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Convert legacy HTML-style formatting to Markdown equivalents.
     * This runs BEFORE Markdown parsing to handle old content gracefully.
     */
    private static function legacyHtmlToMarkdown(string $html): string
    {
        $replacements = [
            // Bold
            '/<b>(.*?)<\/b>/is'        => '**$1**',
            '/<strong>(.*?)<\/strong>/is' => '**$1**',
            // Italic
            '/<i>(.*?)<\/i>/is'        => '*$1*',
            '/<em>(.*?)<\/em>/is'      => '*$1*',
            // Line breaks → actual newlines
            '/<br\s*\/?>/i'            => "\n",
            // Paragraphs
            '/<p>(.*?)<\/p>/is'        => "$1\n\n",
            // Strip remaining tags
            '/<[^>]+>/'                => '',
        ];

        $result = $html;
        foreach ($replacements as $pattern => $replacement) {
            $result = preg_replace($pattern, $replacement, $result) ?? $result;
        }

        // Decode HTML entities (e.g. &amp; → &)
        $result = html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8');

        return $result;
    }

    /**
     * Parse Markdown-lite subset to HTML.
     */
    private static function parseMarkdown(string $text): string
    {
        $converter = static::getConverter();
        $result    = $converter->convert($text);

        return (string) $result->getContent();
    }

    /**
     * Whitelist-based HTML sanitizer.
     * Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes.
     */
    private static function sanitizeHtml(string $html, bool $allowLinks = true): string
    {
        $encodedHtml = mb_encode_numericentity(
            $html,
            [0x80, 0x10FFFF, 0, 0xFFFFFF],
            'UTF-8'
        );

        // Parse with DOMDocument
        $doc = new \DOMDocument('1.0', 'UTF-8');
        // Suppress warnings from malformed fragments
        libxml_use_internal_errors(true);
        $doc->loadHTML(
            '<?xml encoding="UTF-8"><html><body>' . $encodedHtml . '</body></html>',
            LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
        );
        libxml_clear_errors();

        static::cleanNode($doc->getElementsByTagName('body')->item(0), $allowLinks);

        // Serialize back, removing the wrapping html/body
        $body  = $doc->getElementsByTagName('body')->item(0);
        $inner = '';
        foreach ($body->childNodes as $child) {
            $inner .= $doc->saveHTML($child);
        }

        // Fix self-closing <a></a> etc.
        return trim(html_entity_decode($inner, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
    }

    /**
     * Recursively clean a DOMNode — strip forbidden tags/attributes.
     */
    private static function cleanNode(\DOMNode $node, bool $allowLinks = true): void
    {
        $toRemove    = [];
        $toUnwrap    = [];

        foreach ($node->childNodes as $child) {
            if ($child->nodeType === XML_ELEMENT_NODE) {
                if (! $child instanceof \DOMElement) {
                    continue;
                }

                $tag = strtolower($child->nodeName);

                if (! in_array($tag, self::ALLOWED_TAGS, true)) {
                    // Replace element with its text content
                    $toUnwrap[] = $child;
                } else {
                    // Strip disallowed attributes
                    $allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
                    $attrsToRemove = [];
                    foreach ($child->attributes as $attr) {
                        $name = $attr->nodeName;

                        // Allow data-* attributes and class on allowed tags
                        if (str_starts_with($name, 'data-') || $name === 'class') {
                            continue;
                        }

                        if (! in_array($name, $allowedAttrs, true)) {
                            $attrsToRemove[] = $name;
                        }
                    }
                    foreach ($attrsToRemove as $attrName) {
                        $child->removeAttribute($attrName);
                    }

                    // Validate URL-like attributes for image/source/iframe
                    if ($tag === 'img') {
                        $src = $child->getAttribute('src');
                        if ($src && ! static::isSafeUrl($src)) {
                            $toUnwrap[] = $child;
                            continue;
                        }

                        // Validate srcset: ensure each URL is safe; if not, remove the attribute
                        $srcset = $child->getAttribute('srcset');
                        if ($srcset) {
                            $parts = array_map('trim', explode(',', $srcset));
                            $valid = true;
                            foreach ($parts as $part) {
                                if ($part === '') {
                                    continue;
                                }
                                // Each part: "url [descriptor]"
                                $pieces = preg_split('/\s+/', $part);
                                $url = $pieces[0] ?? '';
                                if ($url !== '' && ! static::isSafeUrl($url)) {
                                    $valid = false;
                                    break;
                                }
                            }
                            if (! $valid) {
                                $child->removeAttribute('srcset');
                            }
                        }
                    }

                    if ($tag === 'source') {
                        $src = $child->getAttribute('src') ?: $child->getAttribute('srcset');
                        if ($src) {
                            // For srcset allow comma-separated list; validate each
                            $values = array_map('trim', explode(',', $src));
                            $valid = true;
                            foreach ($values as $v) {
                                if ($v === '') continue;
                                $pieces = preg_split('/\s+/', $v);
                                $url = $pieces[0] ?? '';
                                if ($url !== '' && ! static::isSafeUrl($url)) {
                                    $valid = false;
                                    break;
                                }
                            }
                            if (! $valid) {
                                $toUnwrap[] = $child;
                                continue;
                            }
                        }
                    }

                    if ($tag === 'iframe') {
                        $src = $child->getAttribute('src');
                        if ($src && ! static::isSafeUrl($src)) {
                            $toUnwrap[] = $child;
                            continue;
                        }
                    }

                    // Force external links to be safe
                    if ($tag === 'a') {
                        if (! $allowLinks) {
                            $toUnwrap[] = $child;
                            continue;
                        }

                        $href = $child->getAttribute('href');
                        if ($href && ! static::isSafeUrl($href)) {
                            $toUnwrap[] = $child;
                            continue;
                        }
                        $child->setAttribute('rel', 'noopener noreferrer nofollow');
                        $child->setAttribute('target', '_blank');
                    }

                    // Recurse
                    static::cleanNode($child, $allowLinks);
                }
            }
        }

        // Unwrap forbidden elements (replace with their children)
        foreach ($toUnwrap as $el) {
            while ($el->firstChild) {
                $node->insertBefore($el->firstChild, $el);
            }
            $node->removeChild($el);
        }
    }

    /**
     * Very conservative URL whitelist.
     */
    private static function isSafeUrl(string $url): bool
    {
        $lower = strtolower(trim($url));

        // Allow relative paths and anchors
        if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
            return true;
        }

        // Only allow http(s)
        return str_starts_with($lower, 'http://') || str_starts_with($lower, 'https://');
    }

    /**
     * Count Unicode emoji in a string (basic heuristic).
     */
    private static function countEmoji(string $text): int
    {
        // Match common emoji ranges
        preg_match_all(
            '/[\x{1F300}-\x{1FAD6}\x{2600}-\x{27BF}\x{FE00}-\x{FEFF}]/u',
            $text,
            $matches
        );

        return count($matches[0]);
    }

    /**
     * Lazy-load and cache the Markdown converter.
     */
    private static function getConverter(): MarkdownConverter
    {
        if (static::$converter === null) {
            $env = new Environment([
                'html_input'         => 'strip',
                'allow_unsafe_links' => false,
                'max_nesting_level'  => 10,
            ]);
            $env->addExtension(new CommonMarkCoreExtension());
            $env->addExtension(new AutolinkExtension());
            $env->addExtension(new StrikethroughExtension());

            static::$converter = new MarkdownConverter($env);
        }

        return static::$converter;
    }
}
