<?php

declare(strict_types=1);

namespace Djot\Renderer;

use Closure;
use Djot\Event\RenderEvent;
use Djot\Node\Block\BlockQuote;
use Djot\Node\Block\Caption;
use Djot\Node\Block\CodeBlock;
use Djot\Node\Block\Comment;
use Djot\Node\Block\DefinitionDescription;
use Djot\Node\Block\DefinitionList;
use Djot\Node\Block\DefinitionTerm;
use Djot\Node\Block\Div;
use Djot\Node\Block\Figure;
use Djot\Node\Block\Footnote;
use Djot\Node\Block\Heading;
use Djot\Node\Block\LineBlock;
use Djot\Node\Block\ListBlock;
use Djot\Node\Block\ListItem;
use Djot\Node\Block\Paragraph;
use Djot\Node\Block\RawBlock;
use Djot\Node\Block\Table;
use Djot\Node\Block\TableCell;
use Djot\Node\Block\TableRow;
use Djot\Node\Block\ThematicBreak;
use Djot\Node\Document;
use Djot\Node\Inline\Abbreviation;
use Djot\Node\Inline\Code;
use Djot\Node\Inline\Delete;
use Djot\Node\Inline\Emphasis;
use Djot\Node\Inline\EscapedText;
use Djot\Node\Inline\FootnoteRef;
use Djot\Node\Inline\HardBreak;
use Djot\Node\Inline\Highlight;
use Djot\Node\Inline\Image;
use Djot\Node\Inline\Insert;
use Djot\Node\Inline\Link;
use Djot\Node\Inline\Math;
use Djot\Node\Inline\RawInline;
use Djot\Node\Inline\SoftBreak;
use Djot\Node\Inline\Span;
use Djot\Node\Inline\Strong;
use Djot\Node\Inline\Subscript;
use Djot\Node\Inline\Superscript;
use Djot\Node\Inline\Symbol;
use Djot\Node\Inline\Text;
use Djot\Node\Node;
use Djot\Renderer\Utility\AbbreviationBudgetTrait;
use Djot\Renderer\Utility\EventDispatcherTrait;
use Djot\SafeMode;
use Djot\Util\StringUtil;
use Djot\Util\UrlSafety;

/**
 * Renders AST to HTML
 */
class HtmlRenderer implements RendererInterface
{
    use AbbreviationBudgetTrait;
    use EventDispatcherTrait;

    protected SoftBreakMode $softBreakMode = SoftBreakMode::Newline;

    /**
     * Safe mode configuration (null = disabled)
     */
    protected ?SafeMode $safeMode = null;

    /**
     * Tab width for code blocks and inline code (null = preserve tabs literally,
     * integer = convert each tab to that many spaces).
     *
     * Defaults to null to stay spec-conformant: djot preserves literal tabs. Use
     * TabNormalizationExtension or setCodeBlockTabWidth() to convert them for
     * display (a literal tab in <pre> renders at the browser default tab width).
     */
    protected ?int $codeBlockTabWidth = null;

    /**
     * Round-trip mode adds data attributes to preserve Djot-specific information
     * for perfect HTML→Djot conversion (e.g., list markers, thematic break characters)
     */
    protected bool $roundTripMode = false;

    protected RenderContext $sharedRenderContext;

    protected ?RenderContext $activeRenderContext = null;

    /**
     * Dispatch table mapping node class names to render method names
     *
     * @var array<class-string<\Djot\Node\Node>, string>
     */
    protected array $nodeRenderers = [];

    /**
     * Attribute names valid on <ol> but not on <li>/<dd>. Stripped from
     * <li>/<dd> output to avoid emitting invalid HTML when authors put
     * these names on a list item (e.g. as a postfix {start=5} line).
     *
     * @var array<int, string>
     */
    protected const OL_ONLY_ATTRIBUTES = ['start', 'type', 'reversed'];

    public function __construct(protected bool $xhtml = false)
    {
        $this->sharedRenderContext = new RenderContext();
        $this->initNodeRenderers();
    }

    /**
     * Get the heading ID tracker
     */
    public function getHeadingIdTracker(): HeadingIdTracker
    {
        return $this->getRenderContext()->headingIdTracker;
    }

    /**
     * Initialize the node renderer dispatch table
     *
     * Maps node class names to render method names for O(1) lookup.
     */
    protected function initNodeRenderers(): void
    {
        $this->nodeRenderers = [
            Document::class => 'renderChildren',
            Paragraph::class => 'renderParagraph',
            Heading::class => 'renderHeading',
            CodeBlock::class => 'renderCodeBlock',
            Comment::class => '',
            RawBlock::class => 'renderRawBlock',
            BlockQuote::class => 'renderBlockQuote',
            DefinitionList::class => 'renderDefinitionList',
            DefinitionTerm::class => 'renderDefinitionTerm',
            DefinitionDescription::class => 'renderDefinitionDescription',
            ListBlock::class => 'renderList',
            ListItem::class => 'renderListItem',
            ThematicBreak::class => 'renderThematicBreak',
            Div::class => 'renderDiv',
            Figure::class => 'renderFigure',
            Caption::class => 'renderCaption',
            Table::class => 'renderTable',
            TableRow::class => 'renderTableRow',
            TableCell::class => 'renderTableCell',
            LineBlock::class => 'renderLineBlock',
            Footnote::class => 'renderFootnote',
            Text::class => 'renderText',
            Emphasis::class => 'renderEmphasis',
            Strong::class => 'renderStrong',
            Link::class => 'renderLink',
            Image::class => 'renderImage',
            Code::class => 'renderCode',
            RawInline::class => 'renderRawInline',
            EscapedText::class => 'renderEscapedText',
            Math::class => 'renderMath',
            Symbol::class => 'renderSymbol',
            FootnoteRef::class => 'renderFootnoteRef',
            SoftBreak::class => 'renderSoftBreak',
            HardBreak::class => 'renderHardBreak',
            Span::class => 'renderSpan',
            Highlight::class => 'renderHighlight',
            Superscript::class => 'renderSuperscript',
            Subscript::class => 'renderSubscript',
            Insert::class => 'renderInsert',
            Delete::class => 'renderDelete',
            Abbreviation::class => 'renderAbbreviation',
        ];
    }

    /**
     * Enable safe mode with the given configuration
     */
    public function setSafeMode(?SafeMode $safeMode): self
    {
        $this->safeMode = $safeMode;

        return $this;
    }

    /**
     * Get the current safe mode configuration
     */
    public function getSafeMode(): ?SafeMode
    {
        return $this->safeMode;
    }

    /**
     * Check if safe mode is enabled
     */
    public function isSafeModeEnabled(): bool
    {
        return $this->safeMode !== null;
    }

    /**
     * Set how soft breaks are rendered
     *
     * @param \Djot\Renderer\SoftBreakMode $mode How to render soft breaks:
     *   - Newline: renders as "\n" (default, not visible in browser)
     *   - Space: renders as " " (not visible in browser)
     *   - Break: renders as "<br>" (visible line break)
     */
    public function setSoftBreakMode(SoftBreakMode $mode): self
    {
        $this->softBreakMode = $mode;

        return $this;
    }

    /**
     * Get the current soft break mode
     */
    public function getSoftBreakMode(): SoftBreakMode
    {
        return $this->softBreakMode;
    }

    /**
     * Set tab width for code blocks and inline code.
     *
     * When set to an integer, tabs are converted to that many spaces, for
     * consistent display across browsers and contexts (email clients, RSS
     * readers, etc.) without relying on CSS tab-size. The default is null
     * (preserve literal tabs), which is spec-conformant.
     *
     * @param int|null $width Number of spaces per tab (null to preserve tabs)
     */
    public function setCodeBlockTabWidth(?int $width): self
    {
        $this->codeBlockTabWidth = $width;

        return $this;
    }

    /**
     * Get the current code block tab width
     */
    public function getCodeBlockTabWidth(): ?int
    {
        return $this->codeBlockTabWidth;
    }

    /**
     * Enable round-trip mode to preserve Djot-specific information in HTML output
     *
     * When enabled, adds data attributes for:
     * - List markers (data-marker for non-default markers like *, +, or ))
     * - Thematic break characters (data-char for non-default like * or _)
     *
     * This allows HtmlToDjot to reconstruct the original Djot syntax perfectly.
     */
    public function setRoundTripMode(bool $enabled): self
    {
        $this->roundTripMode = $enabled;

        return $this;
    }

    /**
     * Check if round-trip mode is enabled
     */
    public function isRoundTripMode(): bool
    {
        return $this->roundTripMode;
    }

    /**
     * Register an inline footnote and return its number
     *
     * Used by extensions like InlineFootnotesExtension to add footnotes
     * without requiring a separate footnote definition block.
     *
     * The content renderer callback is invoked lazily during renderFootnotesSection(),
     * ensuring the inline footnote's number is reserved before any nested footnotes
     * in its content are rendered.
     *
     * @param \Closure(): string $contentRenderer Callback that returns the footnote HTML content
     *
     * @return int The assigned footnote number
     */
    public function registerInlineFootnote(Closure $contentRenderer): int
    {
        $context = $this->getRenderContext();
        $context->footnoteCounter++;
        $number = $context->footnoteCounter;

        // Use a synthetic label that cannot collide with user-supplied labels.
        // Djot footnote labels cannot contain ']', so including it here ensures uniqueness.
        $label = '_inline_]' . $number;
        $context->footnoteNumbers[$label] = $number;
        $context->footnoteRefCounts[$label] = 1;

        // Store deferred content renderer
        $context->inlineFootnoteRenderers[$number] = $contentRenderer;

        return $number;
    }

    public function render(Document $document): string
    {
        return $this->withRenderContext(
            $this->sharedRenderContext,
            function () use ($document): string {
                $this->sharedRenderContext->reset();
                $this->resetAbbreviationBudget($document->getSourceLength());

                $html = $this->renderDocumentWithSections($document);

                // Only emit the endnotes section when at least one footnote was
                // actually referenced. A footnote defined but never referenced
                // produces no section; an empty <ol> would otherwise leak.
                if ($this->sharedRenderContext->footnoteNumbers !== []) {
                    $html .= $this->renderFootnotesSection();
                }

                return $html;
            },
        );
    }

    /**
     * Render a single node fragment using the current renderer configuration.
     *
     * This is intended for extensions that need core rendering behavior for an
     * isolated node without re-rendering a full document.
     */
    public function renderNodeFragment(Node $node): string
    {
        return $this->withFragmentContext(fn (): string => $this->renderNode($node));
    }

    /**
     * Render a document fragment without resetting active render state.
     *
     * This is intended for extensions that need block-level rendering for a
     * temporary document while participating in the current render.
     */
    public function renderDocumentFragment(Document $document): string
    {
        return $this->withFragmentContext(fn (): string => $this->renderDocumentWithSections($document));
    }

    /**
     * Render document with section wrapping around headings
     *
     * @phpstan-impure Populates collectedFootnotes and footnoteNumbers during rendering
     */
    protected function renderDocumentWithSections(Document $document): string
    {
        // Reserve every explicit `{#id}` in the document (heading or not)
        // before any heading auto-id is generated, so a later auto-id that
        // would otherwise collide takes the next dedup suffix instead of
        // emitting a duplicate. Mirrors the parser-side pre-reservation in
        // BlockParser::extractHeadingReferences so both passes compute the
        // same heading ids.
        $this->getRenderContext()->headingIdTracker->reserveExplicitIds($document);

        $children = $document->getChildren();
        $html = '';
        /** @var array<int, int> $openSections Level => count of open sections at that level */
        $openSections = [];

        $childCount = count($children);
        for ($i = 0; $i < $childCount; $i++) {
            $child = $children[$i];

            if ($child instanceof Heading) {
                $level = $child->getLevel();
                $customHtml = null;

                // Dispatch render event for heading - allows custom rendering
                if ($this->hasAnyListeners()) {
                    $eventName = 'render.' . $child->getType();
                    $event = new RenderEvent($child);
                    $event->setChildrenRenderer(fn (): string => $this->renderChildren($child));
                    $this->dispatchEvent($eventName, $event);
                    $this->dispatchEvent('render.*', $event);

                    if ($event->isDefaultPrevented()) {
                        $customHtml = $event->getHtml();
                    }
                }

                // Close any sections at same or deeper level
                for ($l = 6; $l >= $level; $l--) {
                    while (($openSections[$l] ?? 0) > 0) {
                        $html .= "</section>\n";
                        $openSections[$l]--;
                    }
                }

                // If event provided custom HTML, use it (without section wrapper)
                if ($customHtml !== null) {
                    $html .= $customHtml;

                    continue;
                }

                // Get the section ID
                $sectionId = $this->getSectionId($child);

                // Check if heading has explicit ID (for round-trip support)
                $explicitIdAttr = '';
                if ($this->roundTripMode && $child->hasAttribute('id')) {
                    $explicitIdAttr = ' data-djot-explicit-id="1"';
                }

                // Open new section
                $html .= '<section id="' . $this->escapeAttribute($sectionId) . '"' . $explicitIdAttr . '>' . "\n";
                if (!isset($openSections[$level])) {
                    $openSections[$level] = 0;
                }
                $openSections[$level]++;

                // Render heading without section wrapper
                $html .= $this->renderHeadingContent($child);
            } else {
                // Non-heading ids were already reserved by the upfront
                // `reserveExplicitIds` pass above; just render the node.
                $html .= $this->renderNode($child);
            }
        }

        // Close all open sections (deepest first)
        for ($l = 6; $l >= 1; $l--) {
            while (($openSections[$l] ?? 0) > 0) {
                $html .= "</section>\n";
                $openSections[$l]--;
            }
        }

        // Add abbreviation definitions for round-trip support
        if ($this->roundTripMode) {
            $abbreviations = $document->getAbbreviations();
            if ($abbreviations !== []) {
                $html .= $this->renderAbbreviationDefinitions($abbreviations);
            }
        }

        return $html;
    }

    /**
     * Render abbreviation definitions as a hidden element for round-trip
     *
     * @param array<string, string> $abbreviations
     */
    protected function renderAbbreviationDefinitions(array $abbreviations): string
    {
        $defs = [];
        foreach ($abbreviations as $abbr => $definition) {
            $defs[] = '*[' . $abbr . ']: ' . $definition;
        }
        $content = implode("\n", $defs);

        return '<template data-djot-abbreviations>' . $this->escape($content) . "</template>\n";
    }

    /**
     * Generate section ID from heading
     */
    protected function getSectionId(Heading $node): string
    {
        return $this->getRenderContext()->headingIdTracker->getIdForHeading($node);
    }

    /**
     * Track ID usage from non-heading elements (like paragraphs with explicit IDs)
     */
    protected function trackIdFromNode(Node $node): void
    {
        if ($node->hasAttribute('id')) {
            $idAttr = $node->getAttribute('id');
            $id = $idAttr ?? '';
            $this->getRenderContext()->headingIdTracker->trackId($id);
        }

        foreach ($node->getChildren() as $child) {
            $this->trackIdFromNode($child);
        }
    }

    /**
     * Render just the heading tag content (without section wrapper)
     */
    protected function renderHeadingContent(Heading $node): string
    {
        $level = $node->getLevel();

        // Don't render id on heading since it's on section
        $attrs = $this->renderAttributesExcluding($node, ['id']);

        return '<h' . $level . $attrs . '>' . $this->renderChildren($node) . '</h' . $level . ">\n";
    }

    /**
     * Render node attributes as HTML string, excluding specified attributes
     *
     * Respects safe mode filtering when enabled.
     *
     * @param \Djot\Node\Node $node
     * @param array<string> $exclude Attribute names to exclude
     */
    public function renderAttributesExcluding(Node $node, array $exclude): string
    {
        return $this->renderAttributeArray($this->getRenderableAttributes($node, $exclude));
    }

    protected function renderNode(Node $node): string
    {
        // Only dispatch events if listeners are registered (avoid object allocation)
        if ($this->hasAnyListeners()) {
            $eventName = 'render.' . $node->getType();
            $event = new RenderEvent($node);

            // Provide lazy children renderer for extensions that need to wrap children
            $event->setChildrenRenderer(fn (): string => $this->renderChildren($node));

            // Call specific listeners
            $this->dispatchEvent($eventName, $event);

            // Call wildcard listeners
            $this->dispatchEvent('render.*', $event);

            // If listener provided custom HTML, use it
            if ($event->isDefaultPrevented()) {
                return $event->getHtml() ?? '';
            }
        }

        // Use dispatch table for O(1) lookup instead of instanceof chain
        $class = $node::class;
        if (isset($this->nodeRenderers[$class])) {
            $method = $this->nodeRenderers[$class];
            if ($method === '') {
                return ''; // Comment nodes
            }

            /** @var string */
            return $this->$method($node);
        }

        return $this->renderChildren($node);
    }

    protected function renderChildren(Node $node): string
    {
        $html = '';
        foreach ($node->getChildren() as $child) {
            $html .= $this->renderNode($child);
        }

        return $html;
    }

    protected function renderParagraph(Paragraph $node): string
    {
        $attrs = $this->renderAttributes($node);
        $content = rtrim($this->renderChildren($node), " \t");

        return '<p' . $attrs . '>' . $content . "</p>\n";
    }

    protected function renderHeading(Heading $node): string
    {
        // This is called when a heading is rendered inside other blocks (blockquote, div, etc.)
        // Section wrapping is ONLY applied at document level by renderDocumentWithSections
        // Inside nested blocks, headings just get id attribute directly
        $level = $node->getLevel();
        $sectionId = $this->getSectionId($node);
        $attrs = $this->renderAttributesExcluding($node, ['id']);

        // Add data attribute for explicit ID round-trip support
        $explicitIdAttr = '';
        if ($this->roundTripMode && $node->hasAttribute('id')) {
            $explicitIdAttr = ' data-djot-explicit-id="1"';
        }

        return '<h' . $level . ' id="' . $this->escapeAttribute($sectionId) . '"' . $explicitIdAttr . $attrs . '>'
            . $this->renderChildren($node) . '</h' . $level . ">\n";
    }

    /**
     * Get plain text content of a node (for generating heading IDs)
     */
    protected function getPlainText(Node $node): string
    {
        return $this->getRenderContext()->headingIdTracker->getPlainText($node);
    }

    protected function renderCodeBlock(CodeBlock $node): string
    {
        $language = $node->getLanguage();
        $attrs = $this->renderAttributes($node);

        $code = $this->escape($node->getContent());

        // Convert tabs to spaces if configured
        if ($this->codeBlockTabWidth !== null) {
            $code = str_replace("\t", str_repeat(' ', $this->codeBlockTabWidth), $code);
        }

        // Add trailing newline inside code block (official djot behavior)
        if ($code !== '' && !str_ends_with($code, "\n")) {
            $code .= "\n";
        }

        // Add data-djot-src for round-trip support
        $djotSrcAttr = '';
        if ($this->roundTripMode) {
            $djotSrc = $this->reconstructCodeBlockSource($node);
            $djotSrcAttr = ' data-djot-src="' . $this->escapeAttribute($djotSrc) . '"';
        }

        if ($language !== null) {
            $langClass = 'class="language-' . $this->escapeAttribute($language) . '"';

            return '<pre' . $attrs . $djotSrcAttr . '><code ' . $langClass . '>' . $code . "</code></pre>\n";
        }

        return '<pre' . $attrs . $djotSrcAttr . '><code>' . $code . "</code></pre>\n";
    }

    /**
     * Reconstruct the original Djot source for a code block
     */
    protected function reconstructCodeBlockSource(CodeBlock $node): string
    {
        $language = $node->getLanguage();
        $content = $node->getContent();

        // Choose a fence that does not conflict with the content
        $fence = StringUtil::findSafeCodeFence($content, 3);

        // Build the code fence
        $djot = $this->renderDjotAttributeBlock($node);
        $djot .= $fence;
        if ($language !== null && $language !== '') {
            $djot .= ' ' . $language;
        }
        $djot .= "\n";
        $djot .= $content;
        if (!str_ends_with($content, "\n")) {
            $djot .= "\n";
        }
        $djot .= $fence . "\n";

        return $djot;
    }

    /**
     * @param \Djot\Node\Node $node
     * @param array<string> $skipAttrs
     * @param array<string> $skipClasses
     */
    protected function renderDjotAttributeBlock(Node $node, array $skipAttrs = [], array $skipClasses = []): string
    {
        $parts = [];

        $id = $node->getAttribute('id');
        if ($id !== null && $id !== '' && !in_array('id', $skipAttrs, true)) {
            $parts[] = '#' . $id;
        }

        if (!in_array('class', $skipAttrs, true)) {
            foreach ($node->getClassList() as $class) {
                if (!in_array($class, $skipClasses, true)) {
                    $parts[] = '.' . $class;
                }
            }
        }

        foreach ($node->getAttributes() as $name => $value) {
            if ($name === 'id' || $name === 'class' || in_array($name, $skipAttrs, true)) {
                continue;
            }

            $parts[] = $value === ''
                ? $name
                : $name . '=' . $this->quoteDjotAttributeValue($value);
        }

        if ($parts === []) {
            return '';
        }

        return '{' . implode(' ', $parts) . "}\n";
    }

    protected function quoteDjotAttributeValue(string $value): string
    {
        if ($value !== '' && preg_match('/^[A-Za-z0-9._:-]+$/', $value) === 1) {
            return $value;
        }

        return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
    }

    protected function renderBlockQuote(BlockQuote $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<blockquote' . $attrs . ">\n" . $this->renderChildren($node) . "</blockquote>\n";
    }

    protected function renderList(ListBlock $node): string
    {
        $attrs = $this->getRenderableAttributes($node);
        $tight = $node->isTight();

        // Render children with tight parameter
        $html = '';
        foreach ($node->getChildren() as $child) {
            if ($child instanceof ListItem) {
                $html .= $this->renderListItem($child, $tight);
            } else {
                $html .= $this->renderNode($child);
            }
        }

        if ($node->getListType() === ListBlock::TYPE_ORDERED) {
            $olAttrs = '';
            $start = $node->getStart();
            $style = $node->getStyle();
            $marker = $node->getMarker();

            // Order: start, then type, then other attrs
            if ($start !== 1) {
                $olAttrs .= ' start="' . $start . '"';
            }
            if ($style !== null) {
                $olAttrs .= ' type="' . $style . '"';
            }
            // Preserve marker for round-trip (only if non-default and round-trip mode enabled)
            if ($this->roundTripMode && $marker !== null && $marker !== '.') {
                $olAttrs .= ' data-marker="' . htmlspecialchars($marker, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"';
            }

            return '<ol' . $olAttrs . $this->renderAttributeArray($attrs) . ">\n" . $html . "</ol>\n";
        }

        if ($node->getListType() === ListBlock::TYPE_TASK) {
            $attrs = $this->mergeAttribute($attrs, 'class', 'task-list');
        }

        // Preserve marker for round-trip (only if non-default and round-trip mode enabled)
        $marker = $node->getMarker();
        $markerAttr = '';
        if ($this->roundTripMode && $marker !== null && $marker !== '-') {
            $markerAttr = ' data-marker="' . htmlspecialchars($marker, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"';
        }

        return '<ul' . $markerAttr . $this->renderAttributeArray($attrs) . ">\n" . $html . "</ul>\n";
    }

    protected function renderListItem(ListItem $node, bool $tight = true): string
    {
        $attrs = $this->renderAttributesExcluding($node, self::OL_ONLY_ATTRIBUTES);
        $content = $this->renderChildren($node);

        if ($tight) {
            // For tight lists, strip paragraph wrapper from content
            // This handles both:
            // 1. Single paragraph: <p>text</p> -> text
            // 2. Paragraph followed by nested list: <p>text</p>\n<ul>... -> text\n<ul>...
            $content = preg_replace('/^<p>(.+?)<\/p>(\n)?/s', '$1$2', $content) ?? $content;
        }

        // Handle task list items
        if ($node->isTask()) {
            $checked = $node->getChecked() ? ' checked=""' : '';
            // Always use xhtml-style format for task list checkboxes
            $checkbox = '<input disabled="" type="checkbox"' . $checked . "/>\n";
            $content = $checkbox . rtrim($content);
        } else {
            $content = rtrim($content);
        }

        // An empty item (bare marker, no content) renders without a blank line.
        if ($content === '') {
            return '<li' . $attrs . ">\n</li>\n";
        }

        // In djot, list item content is always on its own line
        return '<li' . $attrs . ">\n" . $content . "\n</li>\n";
    }

    protected function renderThematicBreak(ThematicBreak $node): string
    {
        $attrs = $this->renderAttributes($node);
        // Preserve character for round-trip (only if non-default and round-trip mode enabled)
        if ($this->roundTripMode && $node->char !== '-') {
            $attrs .= ' data-char="' . htmlspecialchars($node->char, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"';
        }

        return $this->xhtml ? '<hr' . $attrs . " />\n" : '<hr' . $attrs . ">\n";
    }

    protected function renderDiv(Div $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<div' . $attrs . ">\n" . $this->renderChildren($node) . "</div>\n";
    }

    protected function renderLineBlock(LineBlock $node): string
    {
        $attrs = $this->getRenderableAttributes($node);
        $attrs = $this->mergeAttribute($attrs, 'class', 'line-block');

        // Leading-indent placeholders (U+E000) are turned into &nbsp; entities by
        // the shared text escaper, so no extra handling is needed here.
        return '<div' . $this->renderAttributeArray($attrs) . ">\n" . $this->renderChildren($node) . "</div>\n";
    }

    protected function renderFigure(Figure $node): string
    {
        $attrs = $this->renderAttributes($node);
        $html = '<figure' . $attrs . ">\n";

        foreach ($node->getChildren() as $child) {
            if ($child instanceof Caption) {
                // Caption becomes figcaption
                $html .= '<figcaption>' . rtrim($this->renderChildren($child)) . "</figcaption>\n";
            } elseif ($child instanceof Image) {
                // Image rendered directly (not wrapped in p)
                $html .= $this->renderImage($child);
            } else {
                // Other content (blockquote, etc.)
                $html .= $this->renderNode($child);
            }
        }

        return $html . "</figure>\n";
    }

    protected function renderCaption(Caption $node): string
    {
        // Caption is usually rendered as part of figure or table
        // This is a fallback if caption appears standalone
        return '<figcaption>' . rtrim($this->renderChildren($node)) . "</figcaption>\n";
    }

    protected function renderTable(Table $node): string
    {
        $attrs = $this->renderAttributes($node);

        // Add round-trip separator widths attribute if available and in round-trip mode
        if ($this->roundTripMode && $node->getSeparatorWidths() !== null) {
            $widths = implode(',', $node->getSeparatorWidths());
            $attrs .= ' data-djot-col-widths="' . htmlspecialchars($widths, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"';
        }

        $html = '<table' . $attrs . ">\n";

        // Render caption if present
        if ($node->hasCaption()) {
            /** @var \Djot\Node\Block\Caption $caption */
            $caption = $node->getCaption();
            $captionHtml = $this->renderChildren($caption);
            $html .= '<caption>' . rtrim($captionHtml) . "</caption>\n";
        }

        // djot tables don't use thead/tbody - just rows with th or td cells
        foreach ($node->getChildren() as $row) {
            if ($row instanceof TableRow) {
                $html .= $this->renderTableRow($row);
            }
        }

        return $html . "</table>\n";
    }

    protected function renderTableRow(TableRow $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<tr' . $attrs . ">\n" . $this->renderChildren($node) . "</tr>\n";
    }

    protected function renderTableCell(TableCell $node): string
    {
        $tag = $node->isHeader() ? 'th' : 'td';
        $attrs = $this->getRenderableAttributes($node);

        $rowspan = $node->getRowspan();
        if ($rowspan > 1) {
            $attrs['rowspan'] = (string)$rowspan;
        }

        $colspan = $node->getColspan();
        if ($colspan > 1) {
            $attrs['colspan'] = (string)$colspan;
        }

        $alignment = $node->getAlignment();
        if ($alignment !== TableCell::ALIGN_DEFAULT) {
            $attrs = $this->mergeAttribute($attrs, 'style', 'text-align: ' . $alignment . ';');
        }

        return '<' . $tag . $this->renderAttributeArray($attrs) . '>' . $this->renderChildren($node) . '</' . $tag . ">\n";
    }

    protected function renderText(Text $node): string
    {
        return $this->escape($node->getContent());
    }

    protected function renderEmphasis(Emphasis $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<em' . $attrs . '>' . $this->renderChildren($node) . '</em>';
    }

    protected function renderStrong(Strong $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<strong' . $attrs . '>' . $this->renderChildren($node) . '</strong>';
    }

    protected function renderLink(Link $node): string
    {
        $attrs = $this->renderAttributes($node);
        $href = $node->getDestination();
        $title = $node->getTitle();

        // Always-on URL hardening (dangerous scheme denylist), then safe mode
        // may apply a stricter allowlist on top.
        if ($href !== null) {
            $href = $this->sanitizeUrlBaseline($href);
            if ($this->safeMode !== null) {
                $href = $this->safeMode->sanitizeUrl($href);
            }
        }

        $html = '<a';
        // Only output href if destination is set (even if empty)
        if ($href !== null) {
            $html .= ' href="' . $this->escapeAttribute($href) . '"';
        }
        if ($title !== null) {
            $html .= ' title="' . $this->escapeAttribute($title) . '"';
        }

        // In round-trip mode, store reference label for reconstruction
        if ($this->roundTripMode && $node->getReferenceLabel() !== null) {
            $html .= ' data-djot-ref="' . $this->escapeAttribute($node->getReferenceLabel()) . '"';
        }

        // In round-trip mode, mark autolinks for reconstruction
        if ($this->roundTripMode && $node->isAutolink()) {
            $html .= ' data-djot-autolink="1"';
        }

        $html .= $attrs . '>' . $this->renderChildren($node) . '</a>';

        return $html;
    }

    protected function renderImage(Image $node): string
    {
        $attrs = $this->renderAttributes($node);
        $alt = $this->escapeAttribute($node->getAlt());
        $src = $node->getSource();
        $title = $node->getTitle();

        // Always-on URL hardening (dangerous scheme denylist), then safe mode
        // may apply a stricter allowlist on top.
        $src = $this->sanitizeUrlBaseline($src);
        if ($this->safeMode !== null) {
            $src = $this->safeMode->sanitizeUrl($src);
        }

        $html = '<img alt="' . $alt . '" src="' . $this->escapeAttribute($src) . '"';
        if ($title !== null) {
            $html .= ' title="' . $this->escapeAttribute($title) . '"';
        }

        // In round-trip mode, store reference label for reconstruction
        if ($this->roundTripMode && $node->getReferenceLabel() !== null) {
            $html .= ' data-djot-ref="' . $this->escapeAttribute($node->getReferenceLabel()) . '"';
        }

        $html .= $attrs;

        return $this->xhtml ? $html . ' />' : $html . '>';
    }

    protected function renderCode(Code $node): string
    {
        $attrs = $this->renderAttributes($node);
        $content = $this->escape($node->getContent());

        // Convert tabs to spaces if configured
        if ($this->codeBlockTabWidth !== null) {
            $content = str_replace("\t", str_repeat(' ', $this->codeBlockTabWidth), $content);
        }

        return '<code' . $attrs . '>' . $content . '</code>';
    }

    protected function renderSoftBreak(): string
    {
        return match ($this->softBreakMode) {
            SoftBreakMode::Newline => "\n",
            SoftBreakMode::Space => ' ',
            SoftBreakMode::Break => $this->xhtml ? "<br />\n" : "<br>\n",
        };
    }

    protected function renderHardBreak(): string
    {
        return $this->xhtml ? "<br />\n" : "<br>\n";
    }

    protected function renderSpan(Span $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<span' . $attrs . '>' . $this->renderChildren($node) . '</span>';
    }

    protected function renderHighlight(Highlight $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<mark' . $attrs . '>' . $this->renderChildren($node) . '</mark>';
    }

    protected function renderSuperscript(Superscript $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<sup' . $attrs . '>' . $this->renderChildren($node) . '</sup>';
    }

    protected function renderSubscript(Subscript $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<sub' . $attrs . '>' . $this->renderChildren($node) . '</sub>';
    }

    protected function renderInsert(Insert $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<ins' . $attrs . '>' . $this->renderChildren($node) . '</ins>';
    }

    protected function renderDelete(Delete $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<del' . $attrs . '>' . $this->renderChildren($node) . '</del>';
    }

    protected function renderAbbreviation(Abbreviation $node): string
    {
        $title = $node->getTitle();

        // DoS guard: once the cumulative expansion bytes would exceed the
        // budget, degrade to plain key text (no <abbr> wrapper, no title).
        if (!$this->chargeAbbreviationExpansion($title)) {
            return $this->renderChildren($node);
        }

        $attrs = $this->renderAttributes($node);

        return '<abbr title="' . $this->escapeAttribute($title) . '"' . $attrs . '>'
            . $this->renderChildren($node) . '</abbr>';
    }

    protected function renderAttributes(Node $node): string
    {
        return $this->renderAttributeArray($this->getRenderableAttributes($node));
    }

    /**
     * @param \Djot\Node\Node $node
     * @param array<string> $exclude
     *
     * @return array<string, string>
     */
    protected function getRenderableAttributes(Node $node, array $exclude = []): array
    {
        $attrs = $node->getAttributes();
        if (!$attrs) {
            return [];
        }

        if ($exclude !== []) {
            $attrs = array_diff_key($attrs, array_flip($exclude));
        }

        // Always-on hardening: strip event handlers / injection sinks and
        // neutralize dangerous attribute values regardless of safe mode.
        $attrs = $this->sanitizeAttributes($attrs);

        // Safe mode may strip additional names (e.g. style under strict) on top.
        if ($this->safeMode !== null) {
            $attrs = $this->safeMode->filterAttributes($attrs);
        }

        return $attrs;
    }

    /**
     * Always-on attribute hardening, applied regardless of safe mode.
     *
     * Drops event-handler names (`on*`) and the injection sinks `srcdoc` /
     * `formaction`, and blanks a value carrying a dangerous URL scheme or a CSS
     * `expression(...)`. Safe mode (when set) filters further on top. Attribute
     * names are otherwise left as-is (their well-formedness is the parser's job).
     *
     * @param array<string, string> $attrs
     *
     * @return array<string, string>
     */
    protected function sanitizeAttributes(array $attrs): array
    {
        $out = [];
        foreach ($attrs as $key => $value) {
            $name = strtolower((string)$key);
            if (str_starts_with($name, 'on') || $name === 'srcdoc' || $name === 'formaction') {
                continue;
            }
            $out[$key] = $this->sanitizeAttributeValue($name, (string)$value);
        }

        return $out;
    }

    /**
     * Blank an attribute value that carries a dangerous URL scheme or a CSS
     * `expression(...)`. The scheme is normalized (C0 controls + spaces removed)
     * before comparison to defeat `java\tscript:` style evasion.
     */
    protected function sanitizeAttributeValue(string $name, string $value): string
    {
        if (UrlSafety::hasDangerousScheme($value)) {
            return '';
        }
        if ($name === 'style' && $this->hasDangerousCss($value)) {
            return '';
        }

        return $value;
    }

    /**
     * Detect script-bearing / fetching constructs in a CSS `style` value:
     * `expression()` (legacy IE script), `url(...)` (can fetch or carry
     * `javascript:`), `@import`, and the legacy `behavior` / `-moz-binding`
     * script bindings. CSS escapes are decoded and whitespace collapsed first so
     * `expr\65 ssion(` / `expr ession (` cannot evade.
     */
    protected function hasDangerousCss(string $value): bool
    {
        $withoutComments = preg_replace('/\/\*.*?\*\//s', '', $value) ?? $value;
        $decoded = preg_replace_callback(
            '/\\\\([0-9A-Fa-f]{1,6}\s?|.)/s',
            static function (array $m): string {
                $escape = $m[1];
                if (preg_match('/^([0-9A-Fa-f]{1,6})\s?$/', $escape, $hex) === 1) {
                    $codepoint = (int)hexdec($hex[1]);
                    if ($codepoint <= 0 || $codepoint > 0x10FFFF) {
                        return '';
                    }

                    return mb_chr($codepoint, 'UTF-8');
                }

                return $escape;
            },
            $withoutComments,
        ) ?? $withoutComments;
        $compact = strtolower((string)preg_replace('/\s+/', '', $decoded));

        return str_contains($compact, 'expression(')
            || str_contains($compact, 'url(')
            || str_contains($compact, '@import')
            || str_contains($compact, 'behavior:')
            || str_contains($compact, '-moz-binding');
    }

    /**
     * Always-on URL hardening for `href` / `src`, independent of safe mode.
     *
     * Blanks a URL whose (normalized) scheme is one of the dangerous denylist
     * schemes (`javascript`, `vbscript`, `data`, `file`); every other scheme and
     * any scheme-less URL passes. Safe mode may apply a stricter allowlist on
     * top. Scheme detection strips C0 controls + spaces to defeat `java\tscript:`
     * evasion.
     */
    protected function sanitizeUrlBaseline(string $url): string
    {
        return UrlSafety::sanitize($url);
    }

    /**
     * @param array<string, string> $attrs
     */
    protected function renderAttributeArray(array $attrs): string
    {
        if ($attrs === []) {
            return '';
        }

        // Preserve source order of attributes (matching JS reference implementation)
        $html = '';
        foreach ($attrs as $key => $value) {
            $html .= ' ' . $this->escape($key) . '="' . $this->escapeAttribute($value) . '"';
        }

        return $html;
    }

    /**
     * @param array<string, string> $attrs
     * @param string $value
     * @param string $key
     *
     * @return array<string, string>
     */
    protected function mergeAttribute(array $attrs, string $key, string $value): array
    {
        if ($value === '') {
            return $attrs;
        }

        if (!isset($attrs[$key]) || $attrs[$key] === '') {
            $attrs[$key] = $value;

            return $attrs;
        }

        if ($key === 'class') {
            $attrs[$key] .= ' ' . $value;

            return $attrs;
        }

        if ($key === 'style') {
            $existing = rtrim($attrs[$key]);
            if ($existing !== '' && !str_ends_with($existing, ';')) {
                $existing .= ';';
            }
            $attrs[$key] = trim($existing . ' ' . $value);

            return $attrs;
        }

        $attrs[$key] = $value;

        return $attrs;
    }

    protected function escape(string $text): string
    {
        // ENT_NOQUOTES: Don't convert quotes - official djot keeps them literal
        // Only escape <, >, and & for HTML safety
        $escaped = htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5, 'UTF-8');

        // Convert escaped space placeholder (U+E000) to &nbsp; entity
        // Literal NBSP characters in source are preserved as-is
        return str_replace("\u{E000}", '&nbsp;', $escaped);
    }

    /**
     * Escape text for use in HTML attribute values
     *
     * Unlike escape(), this DOES escape quotes since they're in attribute context
     */
    public function escapeAttribute(string $text): string
    {
        // ENT_QUOTES: Escape both single and double quotes for attribute values
        $escaped = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');

        // Convert escaped space placeholder (U+E000) to &nbsp; entity
        return str_replace("\u{E000}", '&nbsp;', $escaped);
    }

    protected function renderRawBlock(RawBlock $node): string
    {
        // Only output if format is HTML
        if ($node->getFormat() !== 'html') {
            return '';
        }

        $content = $node->getContent();

        // Handle raw HTML according to safe mode
        if ($this->safeMode !== null) {
            $mode = $this->safeMode->getRawHtmlMode();
            if ($mode === SafeMode::RAW_HTML_STRIP) {
                return '';
            }
            if ($mode === SafeMode::RAW_HTML_ESCAPE) {
                return $this->escape($content) . "\n";
            }
        }

        return $content . "\n";
    }

    protected function renderRawInline(RawInline $node): string
    {
        $format = $node->getFormat();
        $content = $node->getContent();

        // Handle non-HTML formats
        if ($format !== 'html') {
            // In round-trip mode, preserve non-HTML raw content for potential recovery
            if ($this->roundTripMode) {
                return '<span data-djot-raw="' . $this->escapeAttribute($format) . '">'
                    . $this->escape($content) . '</span>';
            }

            return '';
        }

        // Handle raw HTML according to safe mode
        if ($this->safeMode !== null) {
            $mode = $this->safeMode->getRawHtmlMode();
            if ($mode === SafeMode::RAW_HTML_STRIP) {
                return '';
            }
            if ($mode === SafeMode::RAW_HTML_ESCAPE) {
                return $this->escape($content);
            }
        }

        // In round-trip mode, wrap HTML content for recovery
        if ($this->roundTripMode) {
            return '<span data-djot-raw="html">' . $content . '</span>';
        }

        return $content;
    }

    protected function renderEscapedText(EscapedText $node): string
    {
        $content = $node->getContent();

        // In round-trip mode, wrap escaped text for recovery
        if ($this->roundTripMode) {
            return '<span data-djot-escaped>' . $this->escape($content) . '</span>';
        }

        // Without round-trip mode, just output the escaped character
        return $this->escape($content);
    }

    protected function renderDefinitionList(DefinitionList $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<dl' . $attrs . ">\n" . $this->renderChildren($node) . "</dl>\n";
    }

    protected function renderDefinitionTerm(DefinitionTerm $node): string
    {
        $attrs = $this->renderAttributes($node);

        return '<dt' . $attrs . '>' . $this->renderChildren($node) . "</dt>\n";
    }

    protected function renderDefinitionDescription(DefinitionDescription $node): string
    {
        $attrs = $this->renderAttributesExcluding($node, self::OL_ONLY_ATTRIBUTES);
        $content = $this->renderChildren($node);

        // Content goes on separate lines
        $content = rtrim($content);
        if ($content === '') {
            return '<dd' . $attrs . ">\n</dd>\n";
        }

        return '<dd' . $attrs . ">\n" . $content . "\n</dd>\n";
    }

    protected function renderFootnote(Footnote $node): string
    {
        // Collect footnote for rendering at document end, don't output here
        $label = $node->getLabel();
        $this->getRenderContext()->collectedFootnotes[$label] = $node;

        return '';
    }

    /**
     * Render all collected footnotes as end section
     */
    protected function renderFootnotesSection(): string
    {
        $context = $this->getRenderContext();

        // Pre-render all footnote contents to discover any nested footnote references
        // Keep iterating until no new footnotes are discovered
        $renderedContents = [];
        $processedNumbers = [];

        do {
            $newFootnotes = false;
            foreach ($context->footnoteNumbers as $label => $number) {
                if (isset($processedNumbers[$number])) {
                    continue;
                }
                $processedNumbers[$number] = true;

                if (isset($context->inlineFootnoteRenderers[$number])) {
                    // Inline footnote - invoke deferred renderer
                    $renderedContents[$number] = trim(($context->inlineFootnoteRenderers[$number])());
                } elseif (isset($context->collectedFootnotes[$label])) {
                    // Regular footnote - rendering may discover new footnote references
                    $renderedContents[$number] = trim($this->renderChildren($context->collectedFootnotes[$label]));
                } else {
                    $renderedContents[$number] = '';
                }

                // Check if new footnotes were discovered during rendering
                if (count($context->footnoteNumbers) > count($processedNumbers)) {
                    $newFootnotes = true;
                }
            }
        } while ($newFootnotes);

        // Sort footnotes by their reference number order
        ksort($renderedContents);

        $html = '<section role="doc-endnotes">' . "\n";
        $html .= $this->xhtml ? "<hr />\n" : "<hr>\n";
        $html .= '<ol>' . "\n";

        foreach ($renderedContents as $number => $content) {
            $liAttrs = '';

            // Find the label for this footnote number
            $label = array_search($number, $context->footnoteNumbers, true);

            if ($this->roundTripMode && isset($context->inlineFootnoteRenderers[$number])) {
                $liAttrs = ' data-djot-inline-footnote="1"';
            } elseif ($this->roundTripMode && $label !== false) {
                // Regular footnote - store label for round-trip
                $liAttrs = ' data-djot-footnote-label="' . $this->escapeAttribute((string)$label) . '"';
            }

            $html .= '<li id="fn' . $number . '"' . $liAttrs . '>' . "\n";

            // Get ref count for this footnote
            $refCount = $label !== false ? ($context->footnoteRefCounts[$label] ?? 1) : 1;

            // Generate backlinks - multiple if footnote referenced multiple times
            $backlinks = $this->generateBacklinks($number, $refCount);

            // Add backlink - if content ends with </p>, insert before it
            // Otherwise add as separate paragraph
            if ($content !== '' && preg_match('/^(.*)(<\/p>\n?)$/s', $content, $matches)) {
                $content = $matches[1] . $backlinks . '</p>';
                $html .= $content . "\n";
            } else {
                // Content doesn't end with paragraph (e.g., code block or empty)
                if ($content !== '') {
                    $html .= $content . "\n";
                }
                $html .= '<p>' . $backlinks . '</p>' . "\n";
            }

            $html .= '</li>' . "\n";
        }

        $html .= '</ol>' . "\n";
        $html .= '</section>' . "\n";

        return $html;
    }

    /**
     * Generate backlink(s) for a footnote
     *
     * @param int $number Footnote number
     * @param int $refCount Number of times footnote was referenced
     */
    protected function generateBacklinks(int $number, int $refCount): string
    {
        if ($refCount <= 1) {
            // Single reference - simple backlink
            return '<a href="#fnref' . $number . '" role="doc-backlink">↩︎</a>';
        }

        // Multiple references - generate numbered backlinks
        $links = [];
        for ($i = 1; $i <= $refCount; $i++) {
            $refId = 'fnref' . $number;
            if ($i > 1) {
                $refId .= '-' . $i;
            }
            $links[] = '<a href="#' . $refId . '" role="doc-backlink">↩︎<sup>' . $i . '</sup></a>';
        }

        return implode(' ', $links);
    }

    protected function renderFootnoteRef(FootnoteRef $node): string
    {
        $context = $this->getRenderContext();
        $label = $node->getLabel();

        // Assign number to footnote on first reference
        if (!isset($context->footnoteNumbers[$label])) {
            $context->footnoteCounter++;
            $context->footnoteNumbers[$label] = $context->footnoteCounter;
        }
        $number = $context->footnoteNumbers[$label];

        // Track reference count for this footnote to generate unique IDs
        if (!isset($context->footnoteRefCounts[$label])) {
            $context->footnoteRefCounts[$label] = 0;
        }
        $context->footnoteRefCounts[$label]++;
        $refCount = $context->footnoteRefCounts[$label];

        // Generate unique ID: fnref1 for first, fnref1-2 for second, etc.
        $refId = 'fnref' . $number;
        if ($refCount > 1) {
            $refId .= '-' . $refCount;
        }

        // Format: <a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a>
        $html = '<a id="' . $refId . '" href="#fn' . $number . '" role="doc-noteref"';

        // In round-trip mode, store the original label for reconstruction
        if ($this->roundTripMode) {
            $html .= ' data-djot-footnote-label="' . $this->escapeAttribute($label) . '"';
        }

        $html .= '><sup>' . $number . '</sup></a>';

        return $html;
    }

    protected function renderMath(Math $node): string
    {
        $content = $this->escape($node->getContent());

        if ($node->isDisplay()) {
            return '<span class="math display">\\[' . $content . '\\]</span>';
        }

        return '<span class="math inline">\\(' . $content . '\\)</span>';
    }

    protected function renderSymbol(Symbol $node): string
    {
        // By default, symbols are rendered as their name
        // Could be extended to support emoji mappings
        return ':' . $this->escape($node->getName()) . ':';
    }

    protected function getRenderContext(): RenderContext
    {
        return $this->activeRenderContext ?? $this->sharedRenderContext;
    }

    /**
     * @param \Closure(): string $callback
     */
    protected function withFragmentContext(Closure $callback): string
    {
        $context = $this->activeRenderContext ?? new RenderContext();

        return $this->withRenderContext($context, $callback);
    }

    /**
     * @param \Djot\Renderer\RenderContext $context
     * @param \Closure(): string $callback
     */
    protected function withRenderContext(RenderContext $context, Closure $callback): string
    {
        $previousContext = $this->activeRenderContext;
        $this->activeRenderContext = $context;

        try {
            return $callback();
        } finally {
            $this->activeRenderContext = $previousContext;
        }
    }
}
