<?php

declare(strict_types=1);

namespace PpPhp\Core\Html;

/**
 * A small, safe Markdown → HTML renderer for user-generated content.
 *
 * Deliberately a SUBSET (headings, bold/italic, inline + fenced code, links,
 * images, lists, blockquotes, rules, soft line breaks) — enough for Q&A posts,
 * not a CommonMark clone. The important property is safety, not completeness:
 *
 *   • ALL text is HTML-escaped BEFORE any markdown is applied, so raw HTML in the
 *     input (`<script>…`) is rendered as visible text, never as markup. There is
 *     no raw-HTML passthrough — the classic markdown XSS hole is closed here.
 *   • The output is still run through \PpPhp\Core\Html\Sanitizer by toSafeHtml(),
 *     an independent second layer that also strips bad link schemes and forces
 *     rel="nofollow ugc noopener" on every link.
 *
 * Always store the raw `body_md`; render + sanitise on save (cache `body_html`)
 * or on view. Never trust `body_html` without the sanitiser.
 */
final class Markdown
{
    /** Render Markdown to HTML, then sanitise it. This is the ONLY method callers should use for UGC. */
    public static function toSafeHtml(string $md): string
    {
        return Sanitizer::clean(self::render($md));
    }

    /** Render Markdown to HTML (already HTML-escaped; NOT yet sanitiser-cleaned). */
    public static function render(string $md): string
    {
        $md = str_replace(["\r\n", "\r"], "\n", $md);
        return self::blocks(explode("\n", $md));
    }

    /** Block-level pass. */
    private static function blocks(array $lines): string
    {
        $out = [];
        $n = count($lines);
        $i = 0;

        while ($i < $n) {
            $line = $lines[$i];

            if (trim($line) === '') { $i++; continue; }

            // Fenced code block ```lang … ```
            if (preg_match('/^```+\s*([A-Za-z0-9#+.\-]*)\s*$/', $line, $m)) {
                $lang = $m[1];
                $buf = [];
                $i++;
                while ($i < $n && !preg_match('/^```+\s*$/', $lines[$i])) { $buf[] = $lines[$i]; $i++; }
                $i++;                                                     // consume closing fence
                $code = htmlspecialchars(implode("\n", $buf), ENT_QUOTES, 'UTF-8');
                $cls  = $lang !== '' ? ' class="language-' . htmlspecialchars($lang, ENT_QUOTES, 'UTF-8') . '"' : '';
                $out[] = '<pre><code' . $cls . '>' . $code . '</code></pre>';
                continue;
            }

            // ATX heading — clamped to h2–h4 so it never collides with the page <h1>
            if (preg_match('/^(#{1,6})\s+(.*?)\s*#*\s*$/', $line, $m)) {
                $lvl = max(2, min(4, strlen($m[1]) + 1));
                $out[] = "<h$lvl>" . self::inline($m[2]) . "</h$lvl>";
                $i++;
                continue;
            }

            // Horizontal rule
            if (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $line)) { $out[] = '<hr>'; $i++; continue; }

            // Blockquote (recurse on the un-quoted content)
            if (preg_match('/^\s*>\s?/', $line)) {
                $buf = [];
                while ($i < $n && preg_match('/^\s*>\s?(.*)$/', $lines[$i], $m)) { $buf[] = $m[1]; $i++; }
                $out[] = '<blockquote>' . self::blocks($buf) . '</blockquote>';
                continue;
            }

            // Unordered list
            if (preg_match('/^\s*[-*+]\s+/', $line)) {
                $items = [];
                while ($i < $n && preg_match('/^\s*[-*+]\s+(.*)$/', $lines[$i], $m)) { $items[] = self::inline($m[1]); $i++; }
                $out[] = '<ul>' . implode('', array_map(static fn (string $t): string => "<li>$t</li>", $items)) . '</ul>';
                continue;
            }

            // Ordered list
            if (preg_match('/^\s*\d+\.\s+/', $line)) {
                $items = [];
                $start = null;
                while ($i < $n && preg_match('/^\s*(\d+)\.\s+(.*)$/', $lines[$i], $m)) {
                    if ($start === null) { $start = (int) $m[1]; }
                    $items[] = self::inline($m[2]);
                    $i++;
                }
                $st = ($start !== null && $start !== 1) ? ' start="' . $start . '"' : '';
                $out[] = "<ol$st>" . implode('', array_map(static fn (string $t): string => "<li>$t</li>", $items)) . '</ol>';
                continue;
            }

            // Paragraph — gather until a blank line or the start of another block.
            $buf = [];
            while (
                $i < $n && trim($lines[$i]) !== ''
                && !preg_match('/^(#{1,6})\s|^```|^\s*>\s?|^\s*[-*+]\s+|^\s*\d+\.\s+/', $lines[$i])
                && !preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $lines[$i])
            ) {
                $buf[] = $lines[$i];
                $i++;
            }
            $inlined = array_map(static fn (string $l): string => self::inline($l), $buf);
            $out[] = '<p>' . implode('<br>', $inlined) . '</p>';
        }

        return implode("\n", $out);
    }

    /** Inline pass. Text is escaped first; markdown then wraps it in safe tags. */
    private static function inline(string $text): string
    {
        $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');

        // Protect inline code so nothing else is applied inside it.
        $codes = [];
        $text = preg_replace_callback('/`([^`\n]+)`/', static function (array $m) use (&$codes): string {
            $i = count($codes);
            $codes[$i] = '<code>' . $m[1] . '</code>';                   // inner already escaped
            return "\x02{$i}\x03";
        }, $text) ?? $text;

        // Images before links (both share the [..](..) shape).
        $text = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/', static function (array $m): string {
            $title = isset($m[3]) && $m[3] !== '' ? ' title="' . $m[3] . '"' : '';
            return '<img src="' . $m[2] . '" alt="' . $m[1] . '"' . $title . '>';
        }, $text) ?? $text;

        $text = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/', static function (array $m): string {
            $title = isset($m[3]) && $m[3] !== '' ? ' title="' . $m[3] . '"' : '';
            return '<a href="' . $m[2] . '"' . $title . '>' . $m[1] . '</a>';
        }, $text) ?? $text;

        // Autolinks: <https://…> and conservative bare URLs (schemes checked by the sanitiser).
        $text = preg_replace('/&lt;(https?:\/\/[^\s&]+)&gt;/', '<a href="$1">$1</a>', $text) ?? $text;
        $text = preg_replace('/(?<![">=\/\w])(https?:\/\/[^\s<]+[^\s<.,;:!?)\'"])/', '<a href="$1">$1</a>', $text) ?? $text;

        // Emphasis.
        $text = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $text) ?? $text;
        $text = preg_replace('/__([^_]+)__/', '<strong>$1</strong>', $text) ?? $text;
        $text = preg_replace('/(?<!\*)\*([^*\n]+)\*(?!\*)/', '<em>$1</em>', $text) ?? $text;
        $text = preg_replace('/(?<![_\w])_([^_\n]+)_(?![_\w])/', '<em>$1</em>', $text) ?? $text;

        // Restore protected code spans.
        $text = preg_replace_callback('/\x02(\d+)\x03/', static fn (array $m): string => $codes[(int) $m[1]] ?? '', $text) ?? $text;

        return $text;
    }
}
