<?php
declare(strict_types=1);

namespace App\Content;

final class RichTextFormatter
{
    public function render(string $value, string $mode = 'full'): string
    {
        $text = str_replace(["\r\n", "\r"], "\n", trim($value));
        if ($text === '') return '';
        $mode = $mode === 'comment' ? 'comment' : 'full';

        if ($mode === 'comment') {
            return '<p>' . nl2br($this->inline($text), false) . '</p>';
        }

        $lines = explode("\n", $text);
        $html = [];
        $paragraph = [];
        $listType = null;
        $listItems = [];
        $codeLines = [];
        $inCode = false;

        $flushParagraph = function () use (&$paragraph, &$html): void {
            if ($paragraph === []) return;
            $html[] = '<p>' . nl2br($this->inline(implode("\n", $paragraph)), false) . '</p>';
            $paragraph = [];
        };
        $flushList = function () use (&$listType, &$listItems, &$html): void {
            if ($listType === null || $listItems === []) { $listType = null; $listItems = []; return; }
            $tag = $listType === 'ol' ? 'ol' : 'ul';
            $items = '';
            foreach ($listItems as $item) $items .= '<li>' . $this->inline($item) . '</li>';
            $html[] = '<' . $tag . '>' . $items . '</' . $tag . '>';
            $listType = null;
            $listItems = [];
        };

        foreach ($lines as $line) {
            if (preg_match('/^```\s*$/u', trim($line)) === 1) {
                $flushParagraph();
                $flushList();
                if ($inCode) {
                    $html[] = '<pre><code>' . $this->e(implode("\n", $codeLines)) . '</code></pre>';
                    $codeLines = [];
                    $inCode = false;
                } else {
                    $inCode = true;
                }
                continue;
            }
            if ($inCode) { $codeLines[] = $line; continue; }
            if (trim($line) === '') { $flushParagraph(); $flushList(); continue; }

            if (preg_match('/^(#{2,4})\s+(.+)$/u', $line, $m) === 1) {
                $flushParagraph(); $flushList();
                $level = min(5, strlen($m[1]) + 1); // ## -> h3; never create another page H1.
                $html[] = '<h' . $level . '>' . $this->inline($m[2]) . '</h' . $level . '>';
                continue;
            }
            if (preg_match('/^>\s?(.*)$/u', $line, $m) === 1) {
                $flushParagraph(); $flushList();
                $html[] = '<blockquote><p>' . $this->inline($m[1]) . '</p></blockquote>';
                continue;
            }
            if (preg_match('/^[-*]\s+(.+)$/u', $line, $m) === 1) {
                $flushParagraph();
                if ($listType !== null && $listType !== 'ul') $flushList();
                $listType = 'ul'; $listItems[] = $m[1]; continue;
            }
            if (preg_match('/^\d+[.)]\s+(.+)$/u', $line, $m) === 1) {
                $flushParagraph();
                if ($listType !== null && $listType !== 'ol') $flushList();
                $listType = 'ol'; $listItems[] = $m[1]; continue;
            }
            if ($listType !== null) $flushList();
            $paragraph[] = $line;
        }

        if ($inCode) $html[] = '<pre><code>' . $this->e(implode("\n", $codeLines)) . '</code></pre>';
        $flushParagraph();
        $flushList();
        return implode("\n", $html);
    }

    private function inline(string $text): string
    {
        $tokens = [];
        $store = static function (string $html) use (&$tokens): string {
            $key = '__QRT_' . bin2hex(random_bytes(8)) . '_' . count($tokens) . '__';
            $tokens[$key] = $html;
            return $key;
        };

        // ponytail: inline escapes only. A block marker at line start (#, >, -, 1.)
        // is still unescapable; add a line-start pass if writers ask for it.
        $text = preg_replace_callback('/\\\\([\\\\*`\[\]])/u', function (array $m) use ($store): string {
            return $store($this->e($m[1]));
        }, $text) ?? $text;

        $text = preg_replace_callback('/`([^`\n]+)`/u', function (array $m) use ($store): string {
            return $store('<code>' . $this->e($m[1]) . '</code>');
        }, $text) ?? $text;

        // Images are matched before links, because `![alt](src)` also matches the
        // link pattern and would otherwise render as a literal "!" plus a link.
        $text = preg_replace_callback('/!\[([^\]\n]*)\]\(([^)\s]+)\)/u', function (array $m) use ($store): string {
            $src = trim($m[2]);
            if (!self::isEmbeddableImage($src)) {
                // Not an image we host: fall back to a plain link rather than
                // dropping the author's content silently.
                return '[' . $m[1] . '](' . $m[2] . ')';
            }
            return $store($this->image($src, $m[1]));
        }, $text) ?? $text;

        $text = preg_replace_callback('/\[([^\]\n]+)\]\(([^)\s]+)\)/u', function (array $m) use ($store): string {
            $url = trim($m[2]);
            if (!$this->allowedUrl($url)) return $m[0];
            return $store($this->link($url, $m[1]));
        }, $text) ?? $text;

        $text = preg_replace_callback('~(?<![\w"\'=])(https?://[^\s<>\]\)]+)~iu', function (array $m) use ($store): string {
            $url = rtrim($m[1], '.,;:!?');
            $suffix = substr($m[1], strlen($url));
            if (!$this->allowedUrl($url)) return $m[0];
            return $store($this->link($url, $url)) . $suffix;
        }, $text) ?? $text;

        $escaped = $this->e($text);
        $escaped = preg_replace('/\*\*([^*\n]+)\*\*/u', '<strong>$1</strong>', $escaped) ?? $escaped;
        $escaped = preg_replace('/(?<!\*)\*([^*\n]+)\*(?!\*)/u', '<em>$1</em>', $escaped) ?? $escaped;

        foreach ($tokens as $token => $html) {
            $escaped = str_replace($this->e($token), $html, $escaped);
        }
        return $escaped;
    }

    private function link(string $url, string $label): string
    {
        $href = $this->e($url);
        $text = $this->e($label);
        if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
            return '<a href="' . $href . '">' . $text . '</a>';
        }
        return '<a href="' . $href . '" target="_blank" rel="ugc nofollow noopener noreferrer">' . $text . '</a>';
    }

    /**
     * Only images this site stores may be embedded.
     *
     * An external `<img>` in member content leaks every reader's address and
     * user agent to whoever owns that host, breaks under a strict CSP, and turns
     * into mixed content on an https site. Restricting the source to our own
     * upload directory keeps the widening of the formatter to exactly the case
     * it was widened for.
     */
    public static function isEmbeddableImage(string $url): bool
    {
        $url = trim($url);
        if ($url === '' || preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
            return false;
        }
        // Root-relative only: no scheme, no protocol-relative host, no traversal.
        if (!str_starts_with($url, '/') || str_starts_with($url, '//') || str_contains($url, '..')) {
            return false;
        }

        $path = (string) parse_url($url, PHP_URL_PATH);
        if ($path === '' || $path !== $url) {
            return false; // A query or fragment on an upload path is not ours.
        }

        // The base path is configurable, so the marker is matched anywhere in
        // the path rather than only at its start.
        if (!str_contains($path, '/uploads/')) {
            return false;
        }

        return preg_match('~\.(?:png|jpe?g|gif|webp)$~i', $path) === 1;
    }

    private function image(string $src, string $alt): string
    {
        // Markdown carries no dimensions, so the stylesheet constrains the box.
        // `alt` may legitimately be empty for a decorative image; it is always
        // emitted so the attribute is never missing.
        return '<img src="' . $this->e($src) . '" alt="' . $this->e($alt) . '"'
            . ' loading="lazy" decoding="async" class="rich-text-image">';
    }

    private function allowedUrl(string $url): bool
    {
        $url = trim($url);
        if ($url === '') return false;
        if (str_starts_with($url, '#')) return true;
        if (str_starts_with($url, '/') && !str_starts_with($url, '//')) return true;
        if (preg_match('~^(?:javascript|data|vbscript):~i', $url) === 1) return false; // explicit javascript: rejection
        if (filter_var($url, FILTER_VALIDATE_URL) === false) return false;
        return in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true);
    }

    private function e(string $value): string
    {
        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
    }
}
