# ============================================================================
# AliothPress Page Builder — Block Renderer
#
# Converts JSON block structure to HTML using ap-* CSS classes.
# All blocks automatically inherit theme styles via CSS custom properties.
#
# Usage in Jinja:  {{ page.blocks | render_blocks | safe }}
# Usage in Python: html = render_blocks_to_html(json_string, images_map)
# ============================================================================

import json
import re
from html import unescape as html_unescape
from markupsafe import Markup, escape
from flask import url_for
from i18n import pt


# Max content width (px) per page template — mirrors --ap-page-content-width
# in page-builder-blocks.css. Used to build honest responsive `sizes` hints so
# the browser fetches a variant matching the real display width (e.g. 1200px
# pages must not be served the 800w variant stretched up).
_CONTENT_WIDTH_PX = {
    'default': 768, 'landing-narrow': 768, 'sidebar': 768,
    'full-width': 1200, 'landing': 1200,
}
_DEFAULT_CONTENT_WIDTH = 768


def _content_width_for(template):
    """Resolve a page template name to its max content width in px."""
    return _CONTENT_WIDTH_PX.get(template or 'default', _DEFAULT_CONTENT_WIDTH)


def _ap_content_width():
    """Current page's content width (px), stashed on flask.g by the filter.
    Falls back to the default outside request context (e.g. SEO pre-render)."""
    try:
        from flask import g
        return getattr(g, '_ap_content_width', _DEFAULT_CONTENT_WIDTH) or _DEFAULT_CONTENT_WIDTH
    except Exception:
        return _DEFAULT_CONTENT_WIDTH


def render_blocks_to_html(blocks_json, images_map=None):
    """Convert JSON blocks to HTML string.
    
    Args:
        blocks_json: JSON string or dict with {"blocks": [...], "version": 1}
        images_map: dict {url: Image} for resolving optimized variants
    
    Returns:
        HTML string ready for rendering
    """
    if not blocks_json:
        return ''
    
    if isinstance(blocks_json, str):
        try:
            data = json.loads(blocks_json)
        except (json.JSONDecodeError, TypeError):
            return ''
    else:
        data = blocks_json
    
    blocks = data.get('blocks', []) if isinstance(data, dict) else data
    if not blocks:
        return ''
    
    html_parts = []
    for block in blocks:
        rendered = _render_block(block, images_map or {})
        if rendered:
            html_parts.append(rendered)
    
    return '\n'.join(html_parts)


def _render_block(block, images_map):
    """Render a single block to HTML."""
    block_type = block.get('type', '')
    data = block.get('data', {})
    settings = block.get('settings', {})
    block_id = block.get('id', '')
    
    renderer = BLOCK_RENDERERS.get(block_type)
    if not renderer:
        return f'<!-- unknown block type: {escape(block_type)} -->'
    
    inner_html = renderer(data, images_map)
    if inner_html is None:
        return ''
    
    # Wrap with settings (margin, alignment, visibility, anchor)
    return _wrap_with_settings(inner_html, block_type, block_id, settings)


def _wrap_with_settings(html, block_type, block_id, settings):
    """Apply common settings wrapper to a block."""
    classes = [f'ap-block ap-block--{block_type}']
    style_parts = []
    attrs = []
    
    # Block alignment (position within parent)
    alignment = settings.get('alignment', 'center')
    if alignment:
        classes.append(f'ap-block--align-{alignment}')
    
    # Content alignment (text direction inside block)
    content_alignment = settings.get('content_alignment', '')
    if content_alignment:
        classes.append(f'ap-block--content-{content_alignment}')
    
    # Margins
    mt = settings.get('margin_top')
    mb = settings.get('margin_bottom')
    if mt and mt != 'none':
        classes.append(f'ap-mt-{mt}')
    if mb and mb != 'none':
        classes.append(f'ap-mb-{mb}')
    
    # Padding
    padding = settings.get('padding')
    if padding and padding != 'none':
        classes.append(f'ap-p-{padding}')
    
    # Visibility
    visibility = settings.get('visibility')
    if visibility == 'desktop':
        classes.append('ap-hide-mobile')
    elif visibility == 'mobile':
        classes.append('ap-hide-desktop')
    
    # Custom CSS class
    css_class = settings.get('css_class', '').strip()
    if css_class:
        classes.append(css_class)
    
    # Anchor ID
    anchor = settings.get('anchor_id', '').strip()
    if anchor:
        attrs.append(f'id="{escape(anchor)}"')
    elif block_id:
        attrs.append(f'data-block-id="{escape(block_id)}"')
    
    # Animation
    animation = settings.get('animation')
    if animation and animation != 'none':
        attrs.append(f'data-animation="{escape(animation)}"')
    
    class_str = ' '.join(classes)
    attr_str = ' '.join(attrs)
    
    return f'<div class="{class_str}" {attr_str}>{html}</div>'


# ============================================================================
# INDIVIDUAL BLOCK RENDERERS
# ============================================================================

_RICH_MEDIA_TAG_RE = re.compile(
    r'<(img|iframe|video|audio|table|hr|embed|object|figure)\b', re.IGNORECASE)


def _is_rich_html_empty(html):
    """True when rich-text HTML has no real content — only empty paragraphs,
    <br>, whitespace or &nbsp;. Media/structural tags count as content even
    without text. Mirrors _isRichHtmlEmpty() in the Page Builder editor, and
    also cleans up blocks saved by older versions as '<p></p>' /
    '<p>Start writing here...</p>'-style leftovers that were later emptied."""
    if not html:
        return True
    if _RICH_MEDIA_TAG_RE.search(html):
        return False
    text = re.sub(r'<[^>]*>', '', html)
    text = html_unescape(text).replace('\u00a0', ' ')
    return text.strip() == ''


def _render_heading(data, images_map):
    """Render heading block: <h1>–<h6>"""
    text = data.get('text', '')
    if not text.strip():
        # A heading the author never filled in: skip it instead of emitting
        # an empty <hN>, which pollutes the heading outline (a11y/SEO).
        return None
    level = data.get('level', 2)
    level = max(1, min(6, int(level)))  # Clamp to 1-6
    return f'<h{level}>{escape(text)}</h{level}>'


def _render_text(data, images_map):
    """Render rich text block (HTML content from editor)"""
    html = data.get('html', '')
    if not html or _is_rich_html_empty(html):
        return None
    # Text blocks are prose — headings have their own dedicated Heading block.
    # A stray <h1>–<h6> (e.g. pasted in from a Heading block; the inline editor
    # shows it like normal text so the author can't tell) would otherwise emit a
    # real heading into the DOM and break the page's heading order, which the
    # PageSpeed accessibility/SEO audits flag. Demote any heading tag to <p>,
    # preserving its text and attributes. Nothing else is touched, so pasted
    # lists, tables, links, embeds and inline formatting are left intact.
    html = re.sub(r'<(/?)[hH][1-6]((?:\s[^>]*)?)>', r'<\1p\2>', html)
    # Rich text is already HTML from the editor — render in prose wrapper
    return f'<div class="ap-prose">{html}</div>'


def _render_image(data, images_map):
    """Render image with optional AVIF/WebP variants via <picture>"""
    url = data.get('url', '')
    if not url:
        return None
    
    # Alt text: prefer block-level alt, fallback to library alt_text
    alt = data.get('alt', '')
    img_obj = images_map.get(url)
    if not alt and img_obj and hasattr(img_obj, 'alt_text') and img_obj.alt_text:
        alt = img_obj.alt_text
    alt = escape(alt)
    
    caption = data.get('caption', '')
    link_url = data.get('link_url', '')
    size = data.get('size', 'full')
    
    # Try to find optimized image from images_map
    if img_obj and hasattr(img_obj, 'get_variants') and img_obj.get_variants():
        variants = img_obj.get_variants()
        # Honest `sizes` that matches the chosen display size, so the browser
        # fetches the variant that fits the rendered box (e.g. a 400w file for a
        # Small image) instead of always pulling the full content-width variant.
        # small -> 400w, medium -> 800w, full -> content width; each capped at
        # the page width and the image's native width (never upscale).
        cw = _ap_content_width()
        _tier = {'small': 400, 'medium': 800}.get(size)
        _eff = min(_tier or cw, cw, img_obj.width or cw)
        sizes_val = f'(max-width: {_eff}px) 100vw, {_eff}px'
        # Build <picture> with AVIF, WebP, and original fallback
        picture_parts = ['<picture>']
        
        if variants.get('avif'):
            srcset = img_obj.get_srcset('avif')
            if srcset:
                picture_parts.append(
                    f'<source type="image/avif" srcset="{srcset}" sizes="{sizes_val}">'
                )
        
        if variants.get('webp'):
            srcset = img_obj.get_srcset('webp')
            if srcset:
                picture_parts.append(
                    f'<source type="image/webp" srcset="{srcset}" sizes="{sizes_val}">'
                )
        
        width_attr = f' width="{img_obj.width}"' if img_obj.width else ''
        height_attr = f' height="{img_obj.height}"' if img_obj.height else ''
        color_style = f' style="background-color:{img_obj.dominant_color}"' if img_obj.dominant_color else ''
        
        picture_parts.append(
            f'<img src="{escape(url)}" alt="{alt}"{width_attr}{height_attr} '
            f'loading="lazy" decoding="async"{color_style}>'
        )
        picture_parts.append('</picture>')
        img_html = '\n'.join(picture_parts)
    else:
        img_html = f'<img src="{escape(url)}" alt="{alt}" loading="lazy" decoding="async">'
    
    # Wrap in link if needed
    if link_url:
        img_html = f'<a href="{escape(link_url)}">{img_html}</a>'
    
    # Build figure. Inject the native pixel width so the size-preset CSS can
    # cap the box at the real image width and never upscale (see
    # page-builder-blocks.css → .ap-block-image--small/medium/full).
    native_w = getattr(img_obj, 'width', None) if img_obj else None
    native_style = f' style="--ap-img-native:{native_w}px"' if native_w else ''
    parts = [f'<figure class="ap-block-image ap-block-image--{escape(size)}"{native_style}>']
    parts.append(img_html)
    if caption:
        parts.append(f'<figcaption>{escape(caption)}</figcaption>')
    parts.append('</figure>')
    
    return '\n'.join(parts)


def _render_button(data, images_map):
    """Render button / CTA"""
    text = data.get('text', 'Button')
    if not (text or '').strip():
        # Unlabeled button = unconfigured block: skip rather than publish an
        # empty clickable pill (same policy as image without a URL).
        return None
    url = data.get('url', '#')
    style = data.get('style', 'primary')  # primary, outline, ghost
    target = data.get('target', '_self')
    size = data.get('size', 'md')  # sm, md, lg
    
    target_attr = f' target="{escape(target)}"' if target != '_self' else ''
    rel_attr = ' rel="noopener noreferrer"' if target == '_blank' else ''
    
    return (
        f'<a class="ap-btn ap-btn--{escape(style)} ap-btn--{escape(size)}" '
        f'href="{escape(url)}"{target_attr}{rel_attr}>{escape(text)}</a>'
    )


def _render_divider(data, images_map):
    """Render horizontal divider"""
    style = data.get('style', 'solid')  # solid, dashed, dotted, gradient
    return f'<hr class="ap-block-divider ap-block-divider--{escape(style)}">'


def _render_spacer(data, images_map):
    """Render vertical spacer"""
    height = data.get('height', '40')
    try:
        height = int(height)
    except (ValueError, TypeError):
        height = 40
    return f'<div class="ap-block-spacer" style="height: {height}px" aria-hidden="true"></div>'


def _render_columns(data, images_map):
    """Render responsive columns layout"""
    layout = data.get('layout', '1/2+1/2')
    columns = data.get('columns', [])
    
    if not columns:
        return None
    
    # Map layout string to CSS grid template
    layout_map = {
        '1':               'ap-cols--1',
        '1/2+1/2':         'ap-cols--2',
        '1/3+1/3+1/3':     'ap-cols--3',
        '1/4+1/4+1/4+1/4': 'ap-cols--4',
        '2/3+1/3':         'ap-cols--2-1',
        '1/3+2/3':         'ap-cols--1-2',
    }
    
    layout_class = layout_map.get(layout, 'ap-cols--2')
    
    parts = [f'<div class="ap-columns {layout_class}">']
    
    for col in columns:
        col_blocks = col.get('blocks', [])
        col_html_parts = []
        for block in col_blocks:
            rendered = _render_block(block, images_map)
            if rendered:
                col_html_parts.append(rendered)
        
        parts.append('<div class="ap-column">')
        parts.append('\n'.join(col_html_parts))
        parts.append('</div>')
    
    parts.append('</div>')
    return '\n'.join(parts)


def _build_section_image_set(image_url, img_obj):
    """Build a CSS image-set() value with AVIF/WebP/original variants
    for section background optimization.

    Section backgrounds are rendered at hero size with `cover` scaling,
    so we pick the LARGEST variant of each format (no srcset breakpoints
    apply to CSS backgrounds).

    Returns the image-set() string, or None if no usable variants exist
    (caller should keep the plain url() fallback).
    """
    if not (img_obj and hasattr(img_obj, 'get_variants') and img_obj.get_variants()):
        return None

    variants = img_obj.get_variants()
    sources = []

    for fmt, mime in (('avif', 'image/avif'), ('webp', 'image/webp')):
        fmt_variants = variants.get(fmt) or {}
        if fmt_variants:
            try:
                largest_key = max(fmt_variants.keys(), key=int)
            except (ValueError, TypeError):
                continue
            largest = fmt_variants[largest_key]
            if largest and largest.get('url'):
                sources.append(f'url({largest["url"]}) type("{mime}")')

    if not sources:
        return None

    # Add original URL as final fallback inside image-set, in case
    # the browser supports image-set() with type() but lacks
    # AVIF/WebP decoding.
    sources.append(f'url({image_url})')
    return f'image-set({", ".join(sources)})'


def _render_section(data, images_map):
    """Render section with background (color, image, overlay) and dark mode support"""
    bg_type = data.get('background_type', 'none')  # none, color, image, gradient
    bg_value = data.get('background_value', '')
    bg_image = data.get('background_image', '')
    bg_overlay = data.get('background_overlay', '')
    
    # Dark mode background
    bg_type_dark = data.get('background_type_dark', 'color')
    bg_value_dark = data.get('background_value_dark', '')
    bg_overlay_dark = data.get('background_overlay_dark', '')
    
    padding = data.get('padding', 'lg')
    full_width = data.get('full_width', False)
    text_color = data.get('text_color', 'auto')
    min_height = data.get('min_height', 'auto')
    content_width = data.get('content_width', 'default')
    inner_blocks = data.get('blocks', [])
    
    style_parts = []
    classes = ['ap-section', f'ap-section--pad-{padding}']
    
    if full_width:
        classes.append('ap-section--full')
    
    # Content width variant
    if content_width and content_width != 'default':
        classes.append(f'ap-section--content-{content_width}')
    
    # Light/default background → CSS custom property --ap-section-bg
    if bg_type == 'color' and bg_value:
        style_parts.append(f'--ap-section-bg: {bg_value}')
    elif bg_type == 'image' and (bg_image or bg_value):
        image_url = bg_image or bg_value
        # Plain url() always set as fallback for browsers without
        # image-set() with type() support (kept in --ap-section-bg).
        style_parts.append(f'--ap-section-bg: url({image_url}) center/cover no-repeat')
        # If the image is in the media library with AVIF/WebP variants,
        # ALSO expose them via --ap-section-bg-set. The CSS @supports
        # rule (in page-builder-blocks.css) overrides background-image
        # with image-set() in modern browsers, picking the smallest
        # appropriate variant. Old browsers ignore the @supports block
        # and keep using the plain url() above.
        light_set = _build_section_image_set(image_url, images_map.get(image_url))
        if light_set:
            style_parts.append(f'--ap-section-bg-set: {light_set}')
            classes.append('ap-section--has-image-bg-set')
    elif bg_type == 'gradient' and bg_value:
        style_parts.append(f'--ap-section-bg: {bg_value}')
    
    # Dark mode background → CSS custom property --ap-section-bg-dark
    if bg_type_dark == 'color' and bg_value_dark:
        style_parts.append(f'--ap-section-bg-dark: {bg_value_dark}')
    elif bg_type_dark == 'image' and bg_value_dark:
        # Same fallback strategy as light mode: plain url() always,
        # plus --ap-section-bg-dark-set with image-set() if variants exist.
        style_parts.append(f'--ap-section-bg-dark: url({bg_value_dark}) center/cover no-repeat')
        dark_set = _build_section_image_set(bg_value_dark, images_map.get(bg_value_dark))
        if dark_set:
            style_parts.append(f'--ap-section-bg-dark-set: {dark_set}')
            classes.append('ap-section--has-image-bg-dark-set')
    elif bg_type_dark == 'gradient' and bg_value_dark:
        style_parts.append(f'--ap-section-bg-dark: {bg_value_dark}')
    
    # Text color — apply via CSS class for full cascade into nested blocks
    if text_color == 'light':
        classes.append('ap-section--text-light')
    elif text_color == 'dark':
        classes.append('ap-section--text-dark')
    
    # Min height
    if min_height and min_height != 'auto':
        style_parts.append(f'min-height: {min_height}')
        style_parts.append('display: flex')
        style_parts.append('align-items: center')
    
    style_attr = f' style="{"; ".join(style_parts)}"' if style_parts else ''
    
    parts = [f'<section class="{" ".join(classes)}"{style_attr}>']
    
    # Overlay (light mode — if dark bg is set, only show in light mode)
    if bg_overlay and bg_type in ('image', 'gradient'):
        overlay_class = 'ap-section__overlay'
        if bg_value_dark:
            overlay_class += ' ap-section__overlay--light'
        parts.append(f'<div class="{overlay_class}" style="background: {bg_overlay}"></div>')
    
    # Overlay (dark mode)
    if bg_overlay_dark and bg_type_dark in ('image', 'gradient'):
        parts.append(f'<div class="ap-section__overlay ap-section__overlay--dark" style="background: {bg_overlay_dark}"></div>')
    
    parts.append('<div class="ap-section__inner">')
    
    for block in inner_blocks:
        rendered = _render_block(block, images_map)
        if rendered:
            parts.append(rendered)
    
    parts.append('</div>')
    parts.append('</section>')
    return '\n'.join(parts)


def _render_video(data, images_map):
    """Render video embed as a privacy-preserving facade.
    
    No iframes or third-party requests on page load. The visitor sees a
    locally-cached thumbnail + play button. A click loads the iframe.
    Applies to YouTube and Vimeo. Self-hosted video still uses native
    <video> (no third parties involved).
    """
    source = data.get('source', 'youtube')
    url = data.get('url', '')
    embed_id = data.get('embed_id', '')
    aspect = data.get('aspect_ratio', '16/9')
    title = data.get('title', '') or pt('video_title')
    
    # Self-hosted: no third parties → no facade needed
    if source == 'self' and url:
        return (
            f'<div class="ap-block-video">'
            f'<video controls preload="metadata"><source src="{escape(url)}"></video>'
            f'</div>'
        )
    
    if source not in ('youtube', 'vimeo') or not embed_id:
        return None
    
    esc_id = escape(embed_id)
    esc_title = escape(title)
    esc_source = escape(source)
    aspect_cls = f'ap-aspect-{aspect.replace("/", "-")}'
    play_label = escape(pt('video_play'))
    
    if source == 'youtube':
        iframe_src = f'https://www.youtube-nocookie.com/embed/{esc_id}?rel=0&autoplay=1'
    else:  # vimeo
        iframe_src = f'https://player.vimeo.com/video/{esc_id}?dnt=1&autoplay=1'
    
    thumb_src = f'/media/video-thumb/{esc_source}/{esc_id}.jpg'
    
    return (
        f'<figure class="ap-block-video {aspect_cls}">'
        f'<button type="button" class="ap-block-video__facade" '
        f'data-video-facade data-video-src="{iframe_src}" data-video-source="{esc_source}" '
        f'data-lb-close="{escape(pt("lightbox_close"))}" '
        f'aria-label="{play_label}: {esc_title}">'
        f'<img class="ap-block-video__thumb" src="{thumb_src}" alt="" '
        f'loading="lazy" decoding="async">'
        f'<span class="ap-block-video__play-icon" aria-hidden="true">'
        f'<svg viewBox="0 0 24 24" fill="currentColor">'
        f'<polygon points="9.5 7.5 16.5 12 9.5 16.5"/>'
        f'</svg></span>'
        f'</button>'
        f'</figure>'
    )


def _render_gallery(data, images_map):
    """Render image gallery grid"""
    images = data.get('images', [])
    if not images:
        return None
    
    layout = data.get('layout', 'grid')  # grid, masonry
    cols = data.get('columns', 3)
    gap = data.get('gap', 'md')
    lightbox = data.get('lightbox', True)
    
    lb_attr = ''
    if lightbox:
        # Localized labels for the lightbox controls (picked up by
        # page-builder-public.js) — no hardcoded strings in JS.
        lb_attr = (
            ' data-lightbox="gallery"'
            f' data-lb-prev="{escape(pt("lightbox_prev"))}"'
            f' data-lb-next="{escape(pt("lightbox_next"))}"'
            f' data-lb-close="{escape(pt("lightbox_close"))}"'
        )
    
    parts = [f'<div class="ap-gallery ap-gallery--{layout} ap-gallery--cols-{cols} ap-gallery--gap-{gap}"{lb_attr}>']
    
    # Honest sizes hint so the browser picks the right srcset variant:
    #   cols=1  → single column, image takes 100vw on mobile
    #   cols=2  → always 2 columns (CSS), image takes ~50vw on mobile
    #   cols=3,4 → on mobile the CSS collapses to 2 columns via media
    #              query at 768px, so image takes ~50vw; on desktop it's
    #              the fixed grid cell width (content width / cols).
    # Breakpoint matches the CSS @media (max-width: 768px) rule.
    cw = _ap_content_width()
    if cols == 1:
        sizes_val = f'(max-width: 768px) 100vw, {cw}px'
    else:
        sizes_val = f'(max-width: 768px) 50vw, {int(cw/cols)}px'
    
    for img in images:
        img_url = img.get('url', '')
        img_caption = img.get('caption', '')
        
        if not img_url:
            continue
        
        # Alt text: prefer gallery-item alt, fallback to library alt_text
        img_alt = img.get('alt', '')
        img_obj = images_map.get(img_url)
        if not img_alt and img_obj and hasattr(img_obj, 'alt_text') and img_obj.alt_text:
            img_alt = img_obj.alt_text
        img_alt = escape(img_alt)
        
        parts.append('<figure class="ap-gallery__item">')
        if lightbox:
            # Open the largest optimized variant in the lightbox rather than
            # the uncompressed original. Falls back to the original only when
            # the image has no generated variants.
            lightbox_url = (img_obj.get_largest_url('webp')
                            if img_obj and hasattr(img_obj, 'get_largest_url')
                            else img_url)
            parts.append(f'<a href="{escape(lightbox_url)}" data-caption="{escape(img_caption)}" aria-label="{img_alt or escape(pt("lightbox_view"))}">')
        
        # Build optimized <picture> with AVIF, WebP, and original fallback
        if img_obj and hasattr(img_obj, 'get_variants') and img_obj.get_variants():
            variants = img_obj.get_variants()
            picture_parts = ['<picture>']
            
            if variants.get('avif'):
                srcset = img_obj.get_srcset('avif')
                if srcset:
                    picture_parts.append(
                        f'<source type="image/avif" srcset="{srcset}" sizes="{sizes_val}">'
                    )
            
            if variants.get('webp'):
                srcset = img_obj.get_srcset('webp')
                if srcset:
                    picture_parts.append(
                        f'<source type="image/webp" srcset="{srcset}" sizes="{sizes_val}">'
                    )
            
            width_attr = f' width="{img_obj.width}"' if img_obj.width else ''
            height_attr = f' height="{img_obj.height}"' if img_obj.height else ''
            color_style = f' style="background-color:{img_obj.dominant_color}"' if img_obj.dominant_color else ''
            
            picture_parts.append(
                f'<img src="{escape(img_url)}" alt="{img_alt}"{width_attr}{height_attr} '
                f'loading="lazy" decoding="async"{color_style}>'
            )
            picture_parts.append('</picture>')
            parts.append('\n'.join(picture_parts))
        elif img_obj and hasattr(img_obj, 'get_srcset'):
            srcset = img_obj.get_srcset('webp')
            if srcset:
                parts.append(f'<img src="{escape(img_url)}" alt="{img_alt}" srcset="{srcset}" '
                           f'sizes="{sizes_val}" loading="lazy" decoding="async">')
            else:
                parts.append(f'<img src="{escape(img_url)}" alt="{img_alt}" loading="lazy" decoding="async">')
        else:
            parts.append(f'<img src="{escape(img_url)}" alt="{img_alt}" loading="lazy" decoding="async">')
        
        if lightbox:
            parts.append('</a>')
        if img_caption:
            parts.append(f'<figcaption>{escape(img_caption)}</figcaption>')
        parts.append('</figure>')
    
    parts.append('</div>')
    return '\n'.join(parts)


def _render_video_gallery(data, images_map):
    """Render video gallery — grid of facade thumbnails with lightbox player.
    
    No iframes on initial load. Each video shows a thumbnail (YouTube)
    or styled placeholder (Vimeo) with a play button overlay.
    Click opens a lightbox with the embedded player.
    """
    videos = data.get('videos', [])
    if not videos:
        return None
    
    cols = data.get('columns', 2)
    gap = data.get('gap', 'md')
    
    parts = [f'<div class="ap-video-gallery ap-video-gallery--cols-{cols} ap-video-gallery--gap-{gap}"'
             f' data-lb-close="{escape(pt("lightbox_close"))}">']
    
    for vid in videos:
        embed_id = vid.get('embed_id', '')
        source = vid.get('source', 'youtube')
        title = vid.get('title', '')
        
        if not embed_id:
            continue
        
        esc_title = escape(title) if title else escape(pt('video_title'))
        esc_id = escape(embed_id)
        
        # Build iframe src — only loaded after user click via [data-video-facade]
        if source == 'youtube':
            iframe_src = f'https://www.youtube-nocookie.com/embed/{esc_id}?rel=0&autoplay=1'
        else:
            iframe_src = f'https://player.vimeo.com/video/{esc_id}?dnt=1&autoplay=1'
        
        # Thumbnail always served from our own proxy (cached on disk).
        # Vimeo thumbs go through server-side oEmbed; on failure, the proxy
        # returns a neutral SVG placeholder.
        thumb = f'/media/video-thumb/{escape(source)}/{esc_id}.jpg'
        
        parts.append(
            f'<figure class="ap-video-gallery__item" data-video-facade '
            f'data-video-src="{iframe_src}" data-video-source="{escape(source)}">'
        )
        
        parts.append('<div class="ap-video-gallery__thumb">')
        parts.append(
            f'<img src="{thumb}" alt="{esc_title}" loading="lazy" decoding="async">'
        )
        
        # Play button overlay
        play_label = escape(pt('video_play'))
        parts.append(
            f'<button class="ap-video-gallery__play" aria-label="{play_label}">'
            '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="9.5 7.5 16.5 12 9.5 16.5"/></svg>'
            '</button>'
        )
        
        parts.append('</div>')  # .ap-video-gallery__thumb
        
        if title:
            parts.append(f'<figcaption>{esc_title}</figcaption>')
        
        parts.append('</figure>')
    
    parts.append('</div>')
    return '\n'.join(parts)


def _render_html_block(data, images_map):
    """Render raw HTML block.
    
    SECURITY NOTE: This block renders raw HTML without sanitization.
    It is intentionally left unsanitized because admins use it for embedding
    third-party widgets (analytics, chat, maps, etc.). Only admin/editor
    users can create HTML blocks, so this is trusted content.
    The content editor (TinyMCE) content IS sanitized in app.py via sanitize_html().
    """
    code = data.get('code', '')
    return code if code else None


# Allowed tags for table cell formatting
_TABLE_CELL_ALLOWED = {'b', 'strong', 'i', 'em', 'u', 's', 'br'}

# Tags to remove entirely with content (not just strip the tag)
_REMOVE_ENTIRELY = {'style', 'script', 'head', 'title', 'meta', 'link', 'noscript', 'iframe', 'object', 'embed'}


def _sanitize_table_cell(content):
    """Sanitize table cell — allow only basic inline formatting tags."""
    if not content:
        return ''
    try:
        content = str(content)
        # Decode HTML entities first to prevent double-encoding
        # e.g. &amp; → &, &nbsp; → \u00A0 (then caught by regex below)
        content = html_unescape(content)
        if '<' not in content:
            content = re.sub(r'[\u00A0\u200B\u200C\u200D\u2060\uFEFF\u200E\u200F\u2028\u2029]',
                             lambda m: ' ' if m.group() == '\u00A0' else '', content)
            content = re.sub(r' {2,}', ' ', content).strip()
            return escape(content) if content else ''

        # Strip HTML comments (e.g. clipboard markers <!--StartFragment-->)
        cleaned = re.sub(r'<!--[\s\S]*?-->', '', content)
        # Normalize non-breaking spaces and strip invisible Unicode characters
        cleaned = cleaned.replace('&nbsp;', ' ')
        cleaned = re.sub(r'[\u00A0\u200B\u200C\u200D\u2060\uFEFF\u200E\u200F\u2028\u2029]',
                         lambda m: ' ' if m.group() == '\u00A0' else '', cleaned)
        if '<' not in cleaned:
            cleaned = re.sub(r' {2,}', ' ', cleaned).strip()
            return escape(cleaned) if cleaned else ''

        parts = re.split(r'(<[^>]*>)', cleaned)
        result = []
        skip_until = None
        for part in parts:
            # Skip content inside remove-entirely tags (e.g. <style>...</style>)
            if skip_until:
                m_skip = re.match(r'<(/?)(\w+)', part)
                if m_skip and m_skip.group(1) == '/' and m_skip.group(2).lower() == skip_until:
                    skip_until = None
                continue
            if not part.startswith('<'):
                text = re.sub(r' {2,}', ' ', part)
                result.append(str(escape(text)))
                continue
            m = re.match(r'<(/?)(\w+)([^>]*)>', part)
            if not m:
                result.append(str(escape(part)))
                continue
            closing, tag, attrs = m.group(1), m.group(2).lower(), m.group(3)
            if tag in _REMOVE_ENTIRELY:
                if not closing:
                    skip_until = tag
                continue
            if tag in _TABLE_CELL_ALLOWED:
                if closing:
                    result.append(f'</{tag}>')
                elif tag == 'br':
                    result.append('<br>')
                else:
                    result.append(f'<{tag}>')
            elif tag == 'a':
                if closing:
                    result.append('</a>')
                else:
                    href_m = re.search(r'href=["\']([^"\']*)["\']', attrs)
                    if href_m:
                        href = str(escape(href_m.group(1)))
                        # Preserve target="_blank" if the editor set it
                        # (sidebar "open in new tab" option). Other target
                        # values are stripped — _self is the default, and
                        # named-frame targets are obsolete.
                        # When target="_blank" is kept, force
                        # rel="noopener noreferrer" for security: prevents
                        # reverse tabnabbing (the new tab can otherwise
                        # navigate window.opener) and avoids leaking the
                        # referrer to external sites.
                        target_m = re.search(r'target=["\']([^"\']*)["\']', attrs)
                        if target_m and target_m.group(1) == '_blank':
                            result.append(
                                f'<a href="{href}" target="_blank" rel="noopener noreferrer">'
                            )
                        else:
                            result.append(f'<a href="{href}">')
            # else: strip unknown tags
        return ''.join(result).strip()
    except Exception:
        # Fallback: safe escape if sanitization fails
        return str(escape(str(content))).strip()


def _render_table(data, images_map):
    """Render data table"""
    headers = data.get('headers', [])
    rows = data.get('rows', [])
    striped = data.get('striped', True)
    responsive = data.get('responsive', True)
    
    if not rows:
        return None
    
    classes = ['ap-table']
    if striped:
        classes.append('ap-table--striped')
    
    table_width = data.get('table_width', 'full')
    if table_width == 'auto':
        classes.append('ap-table--auto')
    
    # Per-column alignment
    col_aligns = data.get('column_alignments', [])
    
    def _col_style(idx):
        if idx < len(col_aligns) and col_aligns[idx]:
            return f' style="text-align:{col_aligns[idx]}"'
        return ''
    
    parts = []
    if responsive:
        parts.append('<div class="ap-table-wrapper">')
    
    parts.append(f'<table class="{" ".join(classes)}">')
    
    # Column widths (percentage)
    col_widths = data.get('column_widths', [])
    if any(w for w in col_widths):
        parts.append('<colgroup>')
        for ci in range(len(headers) if headers else (len(rows[0]) if rows else 0)):
            w = col_widths[ci] if ci < len(col_widths) and col_widths[ci] else ''
            if w:
                try:
                    parts.append(f'<col style="width:{int(w)}%">')
                except (ValueError, TypeError):
                    parts.append('<col>')
            else:
                parts.append('<col>')
        parts.append('</colgroup>')
    
    if headers:
        parts.append('<thead><tr>')
        for hi, h in enumerate(headers):
            parts.append(f'<th scope="col"{_col_style(hi)}>{_sanitize_table_cell(h)}</th>')
        parts.append('</tr></thead>')
    
    parts.append('<tbody>')
    for row in rows:
        parts.append('<tr>')
        for ci, cell in enumerate(row):
            parts.append(f'<td{_col_style(ci)}>{_sanitize_table_cell(cell)}</td>')
        parts.append('</tr>')
    parts.append('</tbody>')
    parts.append('</table>')
    
    if responsive:
        parts.append('</div>')
    
    return '\n'.join(parts)


def _render_accordion(data, images_map):
    """Render accordion (FAQ-style expandable sections)"""
    items = data.get('items', [])
    allow_multiple = data.get('allow_multiple', False)
    
    if not items:
        return None
    
    multi_attr = ' data-multi="true"' if allow_multiple else ''
    parts = [f'<div class="ap-accordion"{multi_attr}>']
    
    for i, item in enumerate(items):
        title = escape(item.get('title', ''))
        content = item.get('content', '')  # HTML from rich text editor
        if _is_rich_html_empty(content):
            content = ''  # drop '<p></p>'-style leftovers
        open_attr = ' open' if i == 0 else ''
        
        parts.append(f'<details class="ap-accordion__item"{open_attr}>')
        parts.append(f'<summary class="ap-accordion__title">{title}</summary>')
        parts.append(f'<div class="ap-accordion__content ap-prose">{content}</div>')
        parts.append('</details>')
    
    parts.append('</div>')
    return '\n'.join(parts)


def _render_tabs(data, images_map):
    """Render tabs component"""
    tabs = data.get('tabs', [])
    if not tabs:
        return None
    
    tab_id = data.get('_tab_group_id', 'tabs')
    
    parts = [f'<div class="ap-tabs" data-tabs>']
    
    # Tab buttons
    parts.append('<div class="ap-tabs__nav" role="tablist">')
    for i, tab in enumerate(tabs):
        active = ' is-active' if i == 0 else ''
        parts.append(
            f'<button class="ap-tabs__btn{active}" role="tab" '
            f'data-tab-target="tab-{tab_id}-{i}" '
            f'aria-selected="{"true" if i == 0 else "false"}">'
            f'{escape(tab.get("title", ""))}</button>'
        )
    parts.append('</div>')
    
    # Tab panels
    for i, tab in enumerate(tabs):
        hidden = '' if i == 0 else ' hidden'
        content = tab.get('content', '')
        if _is_rich_html_empty(content):
            content = ''  # drop '<p></p>'-style leftovers
        parts.append(
            f'<div class="ap-tabs__panel" id="tab-{tab_id}-{i}" role="tabpanel"{hidden}>'
            f'<div class="ap-prose">{content}</div>'
            f'</div>'
        )
    
    parts.append('</div>')
    return '\n'.join(parts)


def _render_audio(data, images_map):
    """Render audio player"""
    url = data.get('url', '')
    if not url:
        return None
    
    title = data.get('title', '')
    title_html = f'<p class="ap-audio__title">{escape(title)}</p>' if title else ''
    
    # Determine MIME type from URL extension
    audio_type = ''
    url_lower = url.lower()
    if url_lower.endswith('.mp3'):
        audio_type = ' type="audio/mpeg"'
    elif url_lower.endswith('.ogg') or url_lower.endswith('.oga'):
        audio_type = ' type="audio/ogg"'
    elif url_lower.endswith('.wav'):
        audio_type = ' type="audio/wav"'
    elif url_lower.endswith('.aac') or url_lower.endswith('.m4a'):
        audio_type = ' type="audio/mp4"'
    elif url_lower.endswith('.webm'):
        audio_type = ' type="audio/webm"'
    elif url_lower.endswith('.flac'):
        audio_type = ' type="audio/flac"'
    
    return (
        f'<div class="ap-block-audio">'
        f'{title_html}'
        f'<audio controls preload="metadata"><source src="{escape(url)}"{audio_type}></audio>'
        f'</div>'
    )


def _render_quote(data, images_map):
    """Render blockquote"""
    text = data.get('text', '')
    author = data.get('author', '')
    
    if not text:
        return None
    
    parts = ['<blockquote class="ap-block-quote">']
    parts.append(f'<p>{escape(text)}</p>')
    if author:
        parts.append(f'<cite>— {escape(author)}</cite>')
    parts.append('</blockquote>')
    return '\n'.join(parts)


def _render_code(data, images_map):
    """Render code block with syntax highlighting class"""
    code = data.get('code', '')
    language = data.get('language', '')
    
    if not code:
        return None
    
    lang_class = f' language-{escape(language)}' if language else ''
    lang_attr = f' data-language="{escape(language)}"' if language else ''
    return (
        f'<pre class="ap-code-block{lang_class}"{lang_attr}><code class="{lang_class.strip()}">'
        f'{escape(code)}</code></pre>'
    )


def _render_slideshow(data, images_map):
    """Render slideshow / carousel block.
    
    Supports two modes:
      - image: simple image carousel
      - hero: image carousel with optional button overlay
    
    All interactivity handled by page-builder-public.js
    """
    slides = data.get('slides', [])
    if not slides:
        return None
    
    mode = data.get('mode', 'hero')
    animation = data.get('animation', 'slide')
    autoplay = data.get('autoplay', True)
    interval = data.get('interval', 5)
    show_arrows = data.get('show_arrows', True)
    show_dots = data.get('show_dots', True)
    aspect = data.get('aspect_ratio', '16/9')
    overlay = data.get('overlay', '')
    
    attrs = [
        'data-slideshow',
        f'data-animation="{escape(animation)}"',
        f'data-autoplay="{"true" if autoplay else "false"}"',
        f'data-interval="{int(interval)}"',
    ]
    
    aspect_class = f' ap-slideshow--aspect-{aspect.replace("/", "-")}' if aspect != 'auto' else ''
    mode_class = f' ap-slideshow--{mode}'
    padding = data.get('padding', 'lg')
    pad_class = f' ap-slideshow--pad-{padding}'
    text_color = data.get('text_color', 'light')
    full_width = data.get('full_width', False)
    content_width = data.get('content_width', 'default')
    
    extra_classes = ''
    if text_color == 'light':
        extra_classes += ' ap-slideshow--text-light'
    elif text_color == 'dark':
        extra_classes += ' ap-slideshow--text-dark'
    if full_width:
        extra_classes += ' ap-slideshow--full'
    if content_width and content_width != 'default':
        extra_classes += f' ap-slideshow--content-{content_width}'
    
    parts = [f'<div class="ap-slideshow{mode_class}{aspect_class}{pad_class}{extra_classes}" {" ".join(attrs)}>']
    parts.append('<div class="ap-slideshow__track">')
    
    # Filter out empty slides (no image and no meaningful blocks)
    def _slide_has_content(s):
        if s.get('image'):
            return True
        blocks = s.get('blocks', [])
        # Check that at least one block has actual content
        return any(b.get('type') or b.get('data') for b in blocks)
    
    slides = [s for s in slides if _slide_has_content(s)]
    if not slides:
        return None
    
    for i, slide in enumerate(slides):
        img_url = slide.get('image', '')
        active_class = ' is-active' if i == 0 else ''
        
        parts.append(f'<div class="ap-slideshow__slide{active_class}">')
        
        if img_url:
            # Alt text: prefer slide alt, fallback to library alt_text
            img_alt = slide.get('alt', '')
            img_obj = images_map.get(img_url)
            if not img_alt and img_obj and hasattr(img_obj, 'alt_text') and img_obj.alt_text:
                img_alt = img_obj.alt_text
            img_alt = escape(img_alt)
            
            loading = 'eager' if i == 0 else 'lazy'
            
            # Build optimized <picture> with AVIF, WebP, and original fallback
            if img_obj and hasattr(img_obj, 'get_variants') and img_obj.get_variants():
                variants = img_obj.get_variants()
                picture_parts = ['<picture>']
                
                if variants.get('avif'):
                    srcset = img_obj.get_srcset('avif')
                    if srcset:
                        picture_parts.append(
                            f'<source type="image/avif" srcset="{srcset}" sizes="100vw">'
                        )
                
                if variants.get('webp'):
                    srcset = img_obj.get_srcset('webp')
                    if srcset:
                        picture_parts.append(
                            f'<source type="image/webp" srcset="{srcset}" sizes="100vw">'
                        )
                
                width_attr = f' width="{img_obj.width}"' if img_obj.width else ''
                height_attr = f' height="{img_obj.height}"' if img_obj.height else ''
                color_style = f' style="background-color:{img_obj.dominant_color}"' if img_obj.dominant_color else ''
                
                picture_parts.append(
                    f'<img class="ap-slideshow__img" src="{escape(img_url)}" alt="{img_alt}"'
                    f'{width_attr}{height_attr} loading="{loading}" decoding="async"{color_style}>'
                )
                picture_parts.append('</picture>')
                parts.append('\n'.join(picture_parts))
            elif img_obj and hasattr(img_obj, 'get_srcset'):
                srcset = img_obj.get_srcset('webp')
                if srcset:
                    parts.append(
                        f'<img class="ap-slideshow__img" src="{escape(img_url)}" alt="{img_alt}" '
                        f'srcset="{srcset}" sizes="100vw" loading="{loading}" decoding="async">'
                    )
                else:
                    parts.append(f'<img class="ap-slideshow__img" src="{escape(img_url)}" alt="{img_alt}" loading="{loading}" decoding="async">')
            else:
                parts.append(f'<img class="ap-slideshow__img" src="{escape(img_url)}" alt="{img_alt}" loading="{loading}" decoding="async">')
        
        if overlay:
            parts.append(f'<div class="ap-slideshow__overlay" style="background:{overlay}"></div>')
        
        # Render nested blocks on slide (button, heading, text, etc.)
        inner_blocks = slide.get('blocks', [])
        if inner_blocks:
            parts.append('<div class="ap-slideshow__content">')
            parts.append('<div class="ap-slideshow__inner">')
            for inner_block in inner_blocks:
                rendered = _render_block(inner_block, images_map)
                if rendered:
                    parts.append(rendered)
            parts.append('</div>')
            parts.append('</div>')
        
        parts.append('</div>')
    
    parts.append('</div>')
    
    if show_arrows and len(slides) > 1:
        parts.append(
            '<button class="ap-slideshow__arrow ap-slideshow__arrow--prev" aria-label="Previous slide">'
            '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>'
            '</button>'
            '<button class="ap-slideshow__arrow ap-slideshow__arrow--next" aria-label="Next slide">'
            '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 6 15 12 9 18"/></svg>'
            '</button>'
        )
    
    if show_dots and len(slides) > 1:
        dots_html = '<div class="ap-slideshow__dots">'
        for i in range(len(slides)):
            active = ' is-active' if i == 0 else ''
            dots_html += f'<button class="ap-slideshow__dot{active}" data-slide-index="{i}" aria-label="Go to slide {i+1}"></button>'
        dots_html += '</div>'
        parts.append(dots_html)
    
    parts.append('</div>')
    return '\n'.join(parts)


def _render_form(data, images_map):
    """Render a form block — pulls form structure from DB and generates HTML.
    
    Language-aware: if the form has a translation matching the current page language,
    renders the translated version instead.
    """
    form_id = data.get('form_id')
    if not form_id:
        return f'<div class="ap-block-form ap-block-form--empty">{escape(pt("form_no_selection"))}</div>'
    
    # Import here to avoid circular imports
    from models import Form, Settings
    from flask import g
    
    form = Form.query.get(form_id)
    if not form or not form.is_active:
        return '<!-- form not found or inactive -->'
    
    # Language-aware: try to find translation matching current page language
    page_language = getattr(g, 'current_page_language', None)
    if page_language and form.translation_group and form.language != page_language:
        translated_form = Form.query.filter_by(
            translation_group=form.translation_group,
            language=page_language,
            is_active=True
        ).first()
        if translated_form:
            form = translated_form
    
    fields = form.get_fields()
    settings = form.get_settings()
    show_title = data.get('show_title', True)
    show_description = data.get('show_description', True)

    # WebMCP declarative annotation (public surface). With the owner's
    # public WebMCP surface on, the form carries toolname/tooldescription/
    # toolautosubmit and every named control a toolparamdescription, so
    # WebMCP-capable browsers register submit_form_<id> straight from the
    # markup — the browser-synthesized schema also self-describes the
    # fields, which is what describe_form_<id> used to tell agents.
    # webmcp-public.js sees the toolname attribute and skips its imperative
    # twin on native builds, so each tool exists exactly once; polyfill-only
    # setups (no declarative parsing) keep the imperative pair unchanged.
    # toolautosubmit is deliberate: on the PUBLIC surface agents act
    # directly, exactly like the imperative submit_form_<id> always has —
    # consent mediation belongs to the browser/agent layer, and honeypot,
    # rate limit and validation stay in force server-side. For everyone
    # else the attributes are inert metadata: no visible text, ignored by
    # search crawlers and by browsers without WebMCP.
    webmcp_on = Settings.get('webmcp_public_enabled', 'false') == 'true'

    def tp(text):
        """toolparamdescription attribute for one control ('' when off)."""
        return f' toolparamdescription="{escape(text)}"' if (webmcp_on and text) else ''
    
    parts = [f'<div class="ap-form" data-form-id="{form.id}">']
    
    # Title & description
    if show_title and form.title:
        parts.append(f'<h3 class="ap-form__title">{escape(form.title)}</h3>')
    if show_description and form.description:
        parts.append(f'<p class="ap-form__desc">{escape(form.description)}</p>')
    
    # Form element with translated message attributes for public JS
    msg_success = escape(pt('form_success'))
    msg_error = escape(pt('form_error'))
    msg_network = escape(pt('form_network_error'))
    tool_attrs = ''
    if webmcp_on:
        tool_title = form.title or f'form {form.id}'
        tool_attrs = (f' toolname="submit_form_{form.id}"'
                      f' tooldescription="Fill in and submit the on-page form: {escape(tool_title)}"'
                      ' toolautosubmit')
    parts.append(f'<form class="ap-form__body"{tool_attrs} data-form-submit="{form.id}" data-msg-success="{msg_success}" data-msg-error="{msg_error}" data-msg-network="{msg_network}" novalidate>')
    
    # Fields
    for field in fields:
        ftype = field.get('type', 'text')
        fname = escape(field.get('name', ''))
        flabel = escape(field.get('label', ''))
        fplaceholder = escape(field.get('placeholder', ''))
        frequired = field.get('required', False)
        fwidth = field.get('width', 'full')
        fhelp = field.get('help_text', '')
        req_attr = ' required' if frequired else ''
        req_mark = ' <span class="ap-form__req">*</span>' if frequired else ''
        width_class = f' ap-form__field--{fwidth}'
        # Parameter description for the synthesized tool schema: the field
        # label, extended with the help text when the builder has one.
        fdesc = field.get('label', '') or field.get('name', '')
        if fhelp:
            fdesc = f'{fdesc} - {fhelp}' if fdesc else fhelp
        fparam = tp(fdesc)
        
        parts.append(f'<div class="ap-form__field{width_class}">')
        
        if ftype == 'hidden':
            hidden_desc = tp(f'{field.get("label", "") or "Hidden field"} - keep the prefilled value')
            parts.append(f'<input type="hidden" name="{fname}" value="{fplaceholder}"{hidden_desc}>')
        
        elif ftype == 'textarea':
            parts.append(f'<label class="ap-form__label">{flabel}{req_mark}</label>')
            parts.append(f'<textarea class="ap-form__textarea" name="{fname}" placeholder="{fplaceholder}" rows="4"{req_attr}{fparam}></textarea>')
        
        elif ftype == 'select':
            options = field.get('options', [])
            parts.append(f'<label class="ap-form__label">{flabel}{req_mark}</label>')
            parts.append(f'<select class="ap-form__select" name="{fname}"{req_attr}{fparam}>')
            parts.append(f'<option value="">{fplaceholder or "—"}</option>')
            for opt in options:
                parts.append(f'<option value="{escape(opt)}">{escape(opt)}</option>')
            parts.append('</select>')
        
        elif ftype == 'radio':
            options = field.get('options', [])
            parts.append(f'<label class="ap-form__label">{flabel}{req_mark}</label>')
            parts.append('<div class="ap-form__options">')
            for opt in options:
                oid = f'{fname}_{escape(opt).replace(" ", "_").lower()}'
                parts.append(f'<label class="ap-form__radio"><input type="radio" name="{fname}" value="{escape(opt)}"{req_attr}{fparam}> {escape(opt)}</label>')
            parts.append('</div>')
        
        elif ftype == 'checkbox':
            options = field.get('options', [])
            if options:
                parts.append(f'<label class="ap-form__label">{flabel}{req_mark}</label>')
                parts.append('<div class="ap-form__options">')
                for opt in options:
                    parts.append(f'<label class="ap-form__checkbox"><input type="checkbox" name="{fname}" value="{escape(opt)}"{fparam}> {escape(opt)}</label>')
                parts.append('</div>')
            else:
                parts.append(f'<label class="ap-form__checkbox"><input type="checkbox" name="{fname}"{req_attr}{fparam}> {flabel}</label>')
        
        elif ftype == 'rating':
            # Native radio group: the rating is a real form field that works
            # with the keyboard and screen readers and can be set
            # programmatically (browser agents fill it like any other field).
            # The radios are visually hidden; the star glyphs are their
            # labels. Serialization is unchanged: request.form.get(fname)
            # returns the checked value, or '' when nothing is selected.
            max_rating = field.get('max_rating', 5)
            # The group wrapper is a <fieldset>: per the WebMCP declarative
            # spec, toolparamdescription for a radio GROUP lives on the
            # nearest parent fieldset — descriptions on the individual
            # radios are ignored by the browser's schema synthesizer (it
            # only takes their label text as per-value titles).
            rating_desc = tp(f'{field.get("label", "") or "Rating"} - star rating from 1 (worst) to {max_rating} (best)')
            parts.append(f'<span class="ap-form__label" id="rating_label_{fname}">{flabel}{req_mark}</span>')
            parts.append(f'<fieldset class="ap-form__rating" role="radiogroup" aria-labelledby="rating_label_{fname}" data-max="{max_rating}"{rating_desc}>')
            for i in range(1, max_rating + 1):
                parts.append(f'<label class="ap-form__star"><input type="radio" name="{fname}" value="{i}" aria-label="★ {i}/{max_rating}"{req_attr}><span aria-hidden="true">★</span></label>')
            parts.append('</fieldset>')
        
        elif ftype == 'consent':
            consent_text = field.get('consent_text', '')
            parts.append(f'<label class="ap-form__consent"><input type="checkbox" name="{fname}" value="true"{req_attr}{tp(consent_text or field.get("label", ""))}> <span>{escape(consent_text)}</span></label>')
        
        elif ftype in ('email', 'phone', 'number', 'date', 'url'):
            input_type = {'email': 'email', 'phone': 'tel', 'number': 'number', 'date': 'date', 'url': 'url'}.get(ftype, 'text')
            parts.append(f'<label class="ap-form__label">{flabel}{req_mark}</label>')
            parts.append(f'<input class="ap-form__input" type="{input_type}" name="{fname}" placeholder="{fplaceholder}"{req_attr}{fparam}>')
        
        else:  # text and fallback
            parts.append(f'<label class="ap-form__label">{flabel}{req_mark}</label>')
            parts.append(f'<input class="ap-form__input" type="text" name="{fname}" placeholder="{fplaceholder}"{req_attr}{fparam}>')
        
        if fhelp and ftype != 'hidden':
            parts.append(f'<div class="ap-form__help">{escape(fhelp)}</div>')
        
        parts.append('</div>')
    
    # Honeypot anti-spam
    if settings.get('anti_spam') == 'honeypot':
        parts.append(f'<div class="ap-form__honeypot" aria-hidden="true"><input type="text" name="_hp_website" tabindex="-1" autocomplete="off"{tp("Anti-spam field - leave empty")}></div>')
    
    # Submit button
    submit_label = escape(settings.get('submit_label', '') or pt('form_submit'))
    parts.append(f'<div class="ap-form__field ap-form__field--full">')
    parts.append(f'<button type="submit" class="ap-btn ap-btn--primary ap-btn--md ap-form__submit">{submit_label}</button>')
    parts.append('</div>')
    
    # Success/error messages container
    parts.append('<div class="ap-form__message" style="display:none"></div>')
    
    parts.append('</form>')
    parts.append('</div>')
    
    return '\n'.join(parts)


def _render_ticker(data, images_map):
    """Render logo ticker — infinite horizontal scroll"""
    images = data.get('images', [])
    if not images:
        return None
    
    speed = data.get('speed', 'medium')
    height = data.get('height', 'md')
    
    classes = ['ap-ticker']
    if data.get('pause_on_hover', True):
        classes.append('ap-ticker--pause')
    if data.get('grayscale', True):
        classes.append('ap-ticker--grayscale')
    if data.get('direction', 'rtl') == 'ltr':
        classes.append('ap-ticker--reverse')
    classes.append(f'ap-ticker--{height}')
    
    # Build image tags (matching gallery quality: alt fallback, AVIF/WebP, width/height, decoding)
    img_tags = []
    for img in images:
        url = img.get('url', '')
        if not url:
            continue
        
        # Alt text: prefer ticker-item alt, fallback to library alt_text
        img_alt = img.get('alt', '')
        img_obj = images_map.get(url)
        if not img_alt and img_obj and hasattr(img_obj, 'alt_text') and img_obj.alt_text:
            img_alt = img_obj.alt_text
        img_alt = escape(img_alt)
        
        # Dimensions for CLS prevention
        width_attr = f' width="{img_obj.width}"' if img_obj and img_obj.width else ''
        height_attr = f' height="{img_obj.height}"' if img_obj and img_obj.height else ''
        
        if img_obj and hasattr(img_obj, 'get_variants') and img_obj.get_variants():
            # Full <picture> with AVIF + WebP + fallback
            parts_img = ['<picture>']
            avif_srcset = img_obj.get_srcset('avif') if hasattr(img_obj, 'get_srcset') else ''
            webp_srcset = img_obj.get_srcset('webp') if hasattr(img_obj, 'get_srcset') else ''
            if avif_srcset:
                parts_img.append(f'<source type="image/avif" srcset="{avif_srcset}">')
            if webp_srcset:
                parts_img.append(f'<source type="image/webp" srcset="{webp_srcset}">')
            parts_img.append(
                f'<img src="{escape(url)}" alt="{img_alt}"{width_attr}{height_attr} '
                f'loading="lazy" decoding="async">'
            )
            parts_img.append('</picture>')
            img_tags.append(''.join(parts_img))
        else:
            img_tags.append(
                f'<img src="{escape(url)}" alt="{img_alt}"{width_attr}{height_attr} '
                f'loading="lazy" decoding="async">'
            )
    
    if not img_tags:
        return None
    
    # Repeat images to ensure seamless coverage on wide screens (up to 4K)
    count = len(img_tags)
    repeats = 5 if count <= 5 else (3 if count <= 12 else 2)
    slide_html = '\n'.join(img_tags * repeats)
    
    # Scale animation duration with repetitions to keep consistent perceived speed
    base_speed = {'slow': 60, 'medium': 30, 'fast': 15}
    speed_seconds = base_speed.get(speed, 30) * repeats
    speed_val = f'{speed_seconds}s'
    
    return (
        f'<div class="{" ".join(classes)}" style="--ap-ticker-speed:{speed_val}">'
        f'<div class="ap-ticker__track">'
        f'<div class="ap-ticker__slide">{slide_html}</div>'
        f'<div class="ap-ticker__slide" aria-hidden="true">{slide_html}</div>'
        f'</div></div>'
    )


# ============================================================================
# REGISTRY — maps block type to renderer function
# ============================================================================

BLOCK_RENDERERS = {
    'heading':   _render_heading,
    'text':      _render_text,
    'image':     _render_image,
    'button':    _render_button,
    'divider':   _render_divider,
    'spacer':    _render_spacer,
    'columns':   _render_columns,
    'section':   _render_section,
    'video':     _render_video,
    'gallery':   _render_gallery,
    'video_gallery': _render_video_gallery,
    'html':      _render_html_block,
    'table':     _render_table,
    'accordion': _render_accordion,
    'tabs':      _render_tabs,
    'audio':     _render_audio,
    'quote':     _render_quote,
    'code':      _render_code,
    'slideshow': _render_slideshow,
    'ticker':    _render_ticker,
    'form':      _render_form,
}


def init_block_renderer(app):
    """Register Jinja2 filters and make renderer available in templates.
    
    Call this once during app initialization:
        from block_renderer import init_block_renderer
        init_block_renderer(app)
    """
    @app.template_filter('render_blocks')
    def render_blocks_filter(blocks_json, template=None):
        """Jinja filter: {{ page.blocks | render_blocks(page.template) | safe }}
        
        Automatically passes images_map from Flask g context so that
        builder blocks can resolve optimized image variants and library alt text.
        The page template sets the content width used for responsive image sizes.
        """
        from flask import g
        
        g._ap_content_width = _content_width_for(template)
        # images_map is stored in g by the context processor (see below)
        im = getattr(g, '_ap_images_map', None) or {}
        
        return Markup(render_blocks_to_html(blocks_json, images_map=im))
    
    @app.template_filter('render_blocks_lang')
    def render_blocks_lang_filter(blocks_json, language='en', template=None):
        """Jinja filter with language: {{ page.blocks | render_blocks_lang(page.language, page.template) | safe }}"""
        from flask import g
        g.current_page_language = language
        g._ap_content_width = _content_width_for(template)
        im = getattr(g, '_ap_images_map', None) or {}
        return Markup(render_blocks_to_html(blocks_json, images_map=im))
    
    # Also make the function available as a template global
    app.jinja_env.globals['render_blocks'] = render_blocks_to_html
    # Expose the page-template → content-width helper to templates, so
    # non-builder image markup (e.g. the featured image) can emit an honest
    # responsive `sizes` matching the page width instead of a hardcoded 800.
    app.jinja_env.globals['ap_content_width'] = _content_width_for
