"""Blog defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations

from typing import Any


def setup_blog_pages() -> None:
    """Register blog-specific layouts, page helpers, and load page definitions."""
    _register_blog_layouts()
    _register_blog_helpers()
    _load_blog_page_files()


def _load_blog_page_files() -> None:
    import os
    from shared.sx.pages import load_page_dir
    from shared.sx.jinja_bridge import load_service_components
    # Load blog .sx component definitions + handler definitions
    # __file__ = blog/sxc/pages/__init__.py → blog root is 3 levels up
    blog_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
    load_service_components(blog_dir, service_name="blog")
    load_page_dir(os.path.dirname(__file__), "blog")


# ---------------------------------------------------------------------------
# Shared hydration helpers
# ---------------------------------------------------------------------------

def _add_to_defpage_ctx(**kwargs: Any) -> None:
    from quart import g
    if not hasattr(g, '_defpage_ctx'):
        g._defpage_ctx = {}
    g._defpage_ctx.update(kwargs)


async def _ensure_post_data(slug: str | None) -> None:
    """Load post data and set g.post_data + defpage context.

    Replicates post bp's hydrate_post_data + context_processor.
    """
    from quart import g, abort

    if hasattr(g, 'post_data') and g.post_data:
        await _inject_post_context(g.post_data)
        return

    if not slug:
        abort(404)

    from bp.post.services.post_data import post_data

    is_admin = bool((g.get("rights") or {}).get("admin"))
    p_data = await post_data(slug, g.s, include_drafts=True)
    if not p_data:
        abort(404)

    # Draft access control
    if p_data["post"].get("status") != "published":
        if is_admin:
            pass
        elif g.user and p_data["post"].get("user_id") == g.user.id:
            pass
        else:
            abort(404)

    g.post_data = p_data
    g.post_slug = slug
    await _inject_post_context(p_data)


async def _inject_post_context(p_data: dict) -> None:
    """Add post context_processor data to defpage context."""
    from shared.config import config
    from shared.infrastructure.fragments import fetch_fragment
    from shared.infrastructure.data_client import fetch_data
    from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
    from shared.infrastructure.cart_identity import current_cart_identity

    db_post_id = p_data["post"]["id"]
    post_slug = p_data["post"]["slug"]

    container_nav = await fetch_fragment("relations", "container-nav", params={
        "container_type": "page",
        "container_id": str(db_post_id),
        "post_slug": post_slug,
    })

    ctx: dict = {
        **p_data,
        "base_title": config()["title"],
        "container_nav": container_nav,
    }

    if p_data["post"].get("is_page"):
        ident = current_cart_identity()
        summary_params: dict = {"page_slug": post_slug}
        if ident["user_id"] is not None:
            summary_params["user_id"] = ident["user_id"]
        if ident["session_id"] is not None:
            summary_params["session_id"] = ident["session_id"]
        raw_summary = await fetch_data(
            "cart", "cart-summary", params=summary_params, required=False,
        )
        page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
        ctx["page_cart_count"] = (
            page_summary.count + page_summary.calendar_count + page_summary.ticket_count
        )
        ctx["page_cart_total"] = float(
            page_summary.total + page_summary.calendar_total + page_summary.ticket_total
        )

    _add_to_defpage_ctx(**ctx)


# ---------------------------------------------------------------------------
# Header helpers (moved from sx_components — thin render_to_sx wrappers)
# ---------------------------------------------------------------------------

async def _blog_header_sx(ctx: dict, *, oob: bool = False) -> str:
    from shared.sx.helpers import render_to_sx
    from shared.sx.parser import SxExpr
    return await render_to_sx("menu-row-sx",
        id="blog-row", level=1,
        link_label_content=SxExpr("(div)"),
        child_id="blog-header-child", oob=oob)


async def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str:
    from shared.sx.helpers import render_to_sx
    from shared.sx.parser import SxExpr
    from quart import url_for as qurl

    settings_href = qurl("settings.defpage_settings_home")
    label_sx = await render_to_sx("blog-admin-label")
    nav_sx = await _settings_nav_sx(ctx)

    return await render_to_sx("menu-row-sx",
        id="root-settings-row", level=1,
        link_href=settings_href,
        link_label_content=SxExpr(label_sx),
        nav=SxExpr(nav_sx) if nav_sx else None,
        child_id="root-settings-header-child", oob=oob)


async def _settings_nav_sx(ctx: dict) -> str:
    from shared.sx.helpers import render_to_sx
    return await render_to_sx("blog-settings-nav",
        select_colours=ctx.get("select_colours", ""))


async def _sub_settings_header_sx(row_id: str, child_id: str, href: str,
                                  icon: str, label: str, ctx: dict,
                                  *, oob: bool = False, nav_sx: str = "") -> str:
    from shared.sx.helpers import render_to_sx
    from shared.sx.parser import SxExpr

    label_sx = await render_to_sx("blog-sub-settings-label",
        icon=f"fa fa-{icon}", label=label)
    return await render_to_sx("menu-row-sx",
        id=row_id, level=2,
        link_href=href,
        link_label_content=SxExpr(label_sx),
        nav=SxExpr(nav_sx) if nav_sx else None,
        child_id=child_id, oob=oob)


# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------

def _register_blog_layouts() -> None:
    from shared.sx.layouts import register_custom_layout
    register_custom_layout("blog", _blog_full, _blog_oob)
    register_custom_layout("blog-settings", _settings_full, _settings_oob,
                           mobile_fn=_settings_mobile)
    register_custom_layout("blog-cache", _cache_full, _cache_oob)
    register_custom_layout("blog-snippets", _snippets_full, _snippets_oob)
    register_custom_layout("blog-menu-items", _menu_items_full, _menu_items_oob)
    register_custom_layout("blog-tag-groups", _tag_groups_full, _tag_groups_oob)
    register_custom_layout("blog-tag-group-edit",
                           _tag_group_edit_full, _tag_group_edit_oob)


# --- Blog layout (root + blog header) ---

async def _blog_full(ctx: dict, **kw: Any) -> str:
    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
    from shared.sx.parser import SxExpr
    return await render_to_sx_with_env("blog-layout-full", _ctx_to_env(ctx),
        blog_header=SxExpr(await _blog_header_sx(ctx)))


async def _blog_oob(ctx: dict, **kw: Any) -> str:
    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx
    from shared.sx.parser import SxExpr
    rows = await render_to_sx_with_env("blog-layout-full", _ctx_to_env(ctx),
        blog_header=SxExpr(await _blog_header_sx(ctx)))
    return await oob_header_sx("root-header-child", "blog-header-child", rows)


# --- Settings layout (root + settings header) ---

async def _settings_full(ctx: dict, **kw: Any) -> str:
    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
    from shared.sx.parser import SxExpr
    return await render_to_sx_with_env("settings-layout-full", _ctx_to_env(ctx),
        settings_header=SxExpr(await _settings_header_sx(ctx)))


async def _settings_oob(ctx: dict, **kw: Any) -> str:
    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx
    from shared.sx.parser import SxExpr
    rows = await render_to_sx_with_env("settings-layout-full", _ctx_to_env(ctx),
        settings_header=SxExpr(await _settings_header_sx(ctx)))
    return await oob_header_sx("root-header-child", "root-settings-header-child", rows)


async def _settings_mobile(ctx: dict, **kw: Any) -> str:
    return await _settings_nav_sx(ctx)


# --- Sub-settings helpers ---

async def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
                       endpoint: str, icon: str, label: str) -> str:
    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
    from shared.sx.parser import SxExpr
    from quart import url_for as qurl
    return await render_to_sx_with_env("sub-settings-layout-full", _ctx_to_env(ctx),
        settings_header=SxExpr(await _settings_header_sx(ctx)),
        sub_header=SxExpr(await _sub_settings_header_sx(
            row_id, child_id, qurl(endpoint), icon, label, ctx)))


async def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
                      endpoint: str, icon: str, label: str) -> str:
    from shared.sx.helpers import oob_header_sx, render_to_sx
    from shared.sx.parser import SxExpr
    from quart import url_for as qurl
    settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
    sub_hdr = await _sub_settings_header_sx(
        row_id, child_id, qurl(endpoint), icon, label, ctx)
    sub_oob = await oob_header_sx("root-settings-header-child", child_id, sub_hdr)
    return await render_to_sx("sub-settings-layout-oob",
        settings_header_oob=SxExpr(settings_hdr_oob),
        sub_header_oob=SxExpr(sub_oob))


# --- Cache ---

async def _cache_full(ctx: dict, **kw: Any) -> str:
    return await _sub_settings_full(ctx, "cache-row", "cache-header-child",
                              "defpage_cache_page", "refresh", "Cache")


async def _cache_oob(ctx: dict, **kw: Any) -> str:
    return await _sub_settings_oob(ctx, "cache-row", "cache-header-child",
                             "defpage_cache_page", "refresh", "Cache")


# --- Snippets ---

async def _snippets_full(ctx: dict, **kw: Any) -> str:
    return await _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
                              "defpage_snippets_page", "puzzle-piece", "Snippets")


async def _snippets_oob(ctx: dict, **kw: Any) -> str:
    return await _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
                             "defpage_snippets_page", "puzzle-piece", "Snippets")


# --- Menu Items ---

async def _menu_items_full(ctx: dict, **kw: Any) -> str:
    return await _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
                              "defpage_menu_items_page", "bars", "Menu Items")


async def _menu_items_oob(ctx: dict, **kw: Any) -> str:
    return await _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
                             "defpage_menu_items_page", "bars", "Menu Items")


# --- Tag Groups ---

async def _tag_groups_full(ctx: dict, **kw: Any) -> str:
    return await _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
                              "defpage_tag_groups_page", "tags", "Tag Groups")


async def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
    return await _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
                             "defpage_tag_groups_page", "tags", "Tag Groups")


# --- Tag Group Edit ---

async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
    from quart import request, url_for as qurl
    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
    from shared.sx.parser import SxExpr
    g_id = (request.view_args or {}).get("id")
    return await render_to_sx_with_env("sub-settings-layout-full", _ctx_to_env(ctx),
        settings_header=SxExpr(await _settings_header_sx(ctx)),
        sub_header=SxExpr(await _sub_settings_header_sx(
            "tag-groups-row", "tag-groups-header-child",
            qurl("defpage_tag_group_edit", id=g_id),
            "tags", "Tag Groups", ctx)))


async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
    from quart import request, url_for as qurl
    from shared.sx.helpers import oob_header_sx, render_to_sx
    from shared.sx.parser import SxExpr
    g_id = (request.view_args or {}).get("id")
    settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
    sub_hdr = await _sub_settings_header_sx(
        "tag-groups-row", "tag-groups-header-child",
        qurl("defpage_tag_group_edit", id=g_id),
        "tags", "Tag Groups", ctx)
    sub_oob = await oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
    return await render_to_sx("sub-settings-layout-oob",
        settings_header_oob=SxExpr(settings_hdr_oob),
        sub_header_oob=SxExpr(sub_oob))


# ---------------------------------------------------------------------------
# Rendering helpers (moved from sx_components)
# ---------------------------------------------------------------------------

def _raw_html_sx(html: str) -> str:
    """Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
    from shared.sx.parser import serialize as sx_serialize
    if not html:
        return ""
    return "(raw! " + sx_serialize(html) + ")"


async def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
    """Build the WYSIWYG editor panel HTML for new post/page creation."""
    import os
    from quart import url_for as qurl, current_app
    from shared.browser.app.csrf import generate_csrf_token
    from shared.sx.helpers import render_to_sx

    csrf = generate_csrf_token()
    asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
    editor_css = asset_url_fn("scripts/editor.css")
    editor_js = asset_url_fn("scripts/editor.js")
    sx_editor_js = asset_url_fn("scripts/sx-editor.js")

    upload_image_url = qurl("blog.editor_api.upload_image")
    upload_media_url = qurl("blog.editor_api.upload_media")
    upload_file_url = qurl("blog.editor_api.upload_file")
    oembed_url = qurl("blog.editor_api.oembed_proxy")
    snippets_url = qurl("blog.editor_api.list_snippets")
    unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")

    title_placeholder = "Page title..." if is_page else "Post title..."
    create_label = "Create Page" if is_page else "Create Post"

    parts: list[str] = []

    if save_error:
        parts.append(await render_to_sx("blog-editor-error", error=str(save_error)))

    parts.append(await render_to_sx("blog-editor-form",
        csrf=csrf, title_placeholder=title_placeholder,
        create_label=create_label,
    ))

    parts.append(await render_to_sx("blog-editor-styles", css_href=editor_css))
    parts.append(await render_to_sx("sx-editor-styles"))

    init_js = (
        "console.log('[EDITOR-DEBUG] init script running');\n"
        "(function() {\n"
        "  console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\n"
        "  function init() {\n"
        "    var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
        f"    var uploadUrl = '{upload_image_url}';\n"
        "    var uploadUrls = {\n"
        "      image: uploadUrl,\n"
        f"      media: '{upload_media_url}',\n"
        f"      file:  '{upload_file_url}',\n"
        "    };\n"
        "\n"
        "    var fileInput     = document.getElementById('feature-image-file');\n"
        "    var addBtn        = document.getElementById('feature-image-add-btn');\n"
        "    var deleteBtn     = document.getElementById('feature-image-delete-btn');\n"
        "    var preview       = document.getElementById('feature-image-preview');\n"
        "    var emptyState    = document.getElementById('feature-image-empty');\n"
        "    var filledState   = document.getElementById('feature-image-filled');\n"
        "    var hiddenUrl     = document.getElementById('feature-image-input');\n"
        "    var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
        "    var captionInput  = document.getElementById('feature-image-caption');\n"
        "    var uploading     = document.getElementById('feature-image-uploading');\n"
        "\n"
        "    function showFilled(url) {\n"
        "      preview.src = url;\n"
        "      hiddenUrl.value = url;\n"
        "      emptyState.classList.add('hidden');\n"
        "      filledState.classList.remove('hidden');\n"
        "      uploading.classList.add('hidden');\n"
        "    }\n"
        "\n"
        "    function showEmpty() {\n"
        "      preview.src = '';\n"
        "      hiddenUrl.value = '';\n"
        "      hiddenCaption.value = '';\n"
        "      captionInput.value = '';\n"
        "      emptyState.classList.remove('hidden');\n"
        "      filledState.classList.add('hidden');\n"
        "      uploading.classList.add('hidden');\n"
        "    }\n"
        "\n"
        "    function uploadFile(file) {\n"
        "      emptyState.classList.add('hidden');\n"
        "      uploading.classList.remove('hidden');\n"
        "      var fd = new FormData();\n"
        "      fd.append('file', file);\n"
        "      fetch(uploadUrl, {\n"
        "        method: 'POST',\n"
        "        body: fd,\n"
        "        headers: { 'X-CSRFToken': csrfToken },\n"
        "      })\n"
        "      .then(function(r) {\n"
        "        if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
        "        return r.json();\n"
        "      })\n"
        "      .then(function(data) {\n"
        "        var url = data.images && data.images[0] && data.images[0].url;\n"
        "        if (url) showFilled(url);\n"
        "        else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
        "      })\n"
        "      .catch(function(e) {\n"
        "        showEmpty();\n"
        "        alert(e.message);\n"
        "      });\n"
        "    }\n"
        "\n"
        "    addBtn.addEventListener('click', function() { fileInput.click(); });\n"
        "    preview.addEventListener('click', function() { fileInput.click(); });\n"
        "    deleteBtn.addEventListener('click', function(e) {\n"
        "      e.stopPropagation();\n"
        "      showEmpty();\n"
        "    });\n"
        "    fileInput.addEventListener('change', function() {\n"
        "      if (fileInput.files && fileInput.files[0]) {\n"
        "        uploadFile(fileInput.files[0]);\n"
        "        fileInput.value = '';\n"
        "      }\n"
        "    });\n"
        "    captionInput.addEventListener('input', function() {\n"
        "      hiddenCaption.value = captionInput.value;\n"
        "    });\n"
        "\n"
        "    var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
        "    function autoResize() {\n"
        "      excerpt.style.height = 'auto';\n"
        "      excerpt.style.height = excerpt.scrollHeight + 'px';\n"
        "    }\n"
        "    excerpt.addEventListener('input', autoResize);\n"
        "    autoResize();\n"
        "\n"
        "    window.mountEditor('lexical-editor', {\n"
        "      initialJson: null,\n"
        "      csrfToken: csrfToken,\n"
        "      uploadUrls: uploadUrls,\n"
        f"      oembedUrl: '{oembed_url}',\n"
        f"      unsplashApiKey: '{unsplash_key}',\n"
        f"      snippetsUrl: '{snippets_url}',\n"
        "    });\n"
        "\n"
        "    if (typeof SxEditor !== 'undefined') {\n"
        "      SxEditor.mount('sx-editor', {\n"
        "        initialSx: (document.getElementById('sx-content-input') || {}).value || null,\n"
        "        csrfToken: csrfToken,\n"
        "        uploadUrls: uploadUrls,\n"
        f"        oembedUrl: '{oembed_url}',\n"
        "        onChange: function(sx) {\n"
        "          document.getElementById('sx-content-input').value = sx;\n"
        "        }\n"
        "      });\n"
        "    }\n"
        "\n"
        "    document.addEventListener('keydown', function(e) {\n"
        "      if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
        "        e.preventDefault();\n"
        "        document.getElementById('post-new-form').requestSubmit();\n"
        "      }\n"
        "    });\n"
        "  }\n"
        "\n"
        "  if (typeof window.mountEditor === 'function') {\n"
        "    init();\n"
        "  } else {\n"
        "    var _t = setInterval(function() {\n"
        "      if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
        "    }, 50);\n"
        "  }\n"
        "})();\n"
    )
    parts.append(await render_to_sx("blog-editor-scripts",
                         js_src=editor_js,
                         sx_editor_js_src=sx_editor_js,
                         init_js=init_js))

    from shared.sx.parser import SxExpr
    return await render_to_sx("blog-editor-panel",
        parts=SxExpr("(<> " + " ".join(parts) + ")")) if parts else ""


# ---------------------------------------------------------------------------
# Page helpers (async functions available in .sx defpage expressions)
# ---------------------------------------------------------------------------

def _register_blog_helpers() -> None:
    from shared.sx.pages import register_page_helpers
    register_page_helpers("blog", {
        "editor-content": _h_editor_content,
        "editor-page-content": _h_editor_page_content,
        "post-admin-content": _h_post_admin_content,
        "post-data-content": _h_post_data_content,
        "post-preview-content": _h_post_preview_content,
        "post-entries-content": _h_post_entries_content,
        "post-settings-content": _h_post_settings_content,
        "post-edit-content": _h_post_edit_content,
    })


# --- Editor helpers ---

async def _h_editor_content(**kw):
    return await render_editor_panel()


async def _h_editor_page_content(**kw):
    return await render_editor_panel(is_page=True)


# --- Post admin helpers ---

async def _h_post_admin_content(slug=None, **kw):
    await _ensure_post_data(slug)
    return '(div :class "pb-8")'


async def _h_post_data_content(slug=None, **kw):
    await _ensure_post_data(slug)
    from quart import g
    from markupsafe import escape as esc

    original_post = getattr(g, "post_data", {}).get("original_post")
    if original_post is None:
        return _raw_html_sx('<div class="px-4 py-8 text-stone-400">No post data available.</div>')

    tablename = getattr(original_post, "__tablename__", "?")

    def _render_scalar_table(obj):
        rows = []
        for col in obj.__mapper__.columns:
            key = col.key
            if key == "_sa_instance_state":
                continue
            val = getattr(obj, key, None)
            if val is None:
                val_html = '<span class="text-neutral-400">\u2014</span>'
            elif hasattr(val, "isoformat"):
                val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(val.isoformat())}</code></pre>'
            elif isinstance(val, str):
                val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs">{esc(val)}</pre>'
            else:
                val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(str(val))}</code></pre>'
            rows.append(
                f'<tr class="border-t border-neutral-200 align-top">'
                f'<td class="px-3 py-2 whitespace-nowrap text-neutral-600 align-top">{esc(key)}</td>'
                f'<td class="px-3 py-2 align-top">{val_html}</td></tr>'
            )
        return (
            '<div class="w-full overflow-x-auto sm:overflow-visible">'
            '<table class="w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden">'
            '<thead class="bg-neutral-50/70"><tr>'
            '<th class="px-3 py-2 text-left font-medium w-40 sm:w-56">Field</th>'
            '<th class="px-3 py-2 text-left font-medium">Value</th>'
            '</tr></thead><tbody>' + "".join(rows) + '</tbody></table></div>'
        )

    def _render_model(obj, depth=0, max_depth=2):
        parts = [_render_scalar_table(obj)]
        rel_parts = []
        for rel in obj.__mapper__.relationships:
            rel_name = rel.key
            loaded = rel_name in obj.__dict__
            value = getattr(obj, rel_name, None) if loaded else None
            cardinality = "many" if rel.uselist else "one"
            cls_name = rel.mapper.class_.__name__
            loaded_label = "" if loaded else " \u2022 <em>not loaded</em>"

            inner = ""
            if value is None:
                inner = '<span class="text-neutral-400">\u2014</span>'
            elif rel.uselist:
                items = list(value) if value else []
                inner = f'<div class="text-neutral-500 mb-2">{len(items)} item{"" if len(items) == 1 else "s"}</div>'
                if items and depth < max_depth:
                    sub_rows = []
                    for i, it in enumerate(items, 1):
                        ident_parts = []
                        for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
                            if k in it.__mapper__.c:
                                v = getattr(it, k, "")
                                ident_parts.append(f"{k}={v}")
                        summary = " \u2022 ".join(ident_parts) if ident_parts else str(it)
                        child_html = ""
                        if depth < max_depth:
                            child_html = f'<div class="mt-2 pl-3 border-l border-neutral-200">{_render_model(it, depth + 1, max_depth)}</div>'
                        else:
                            child_html = '<div class="mt-1 text-xs text-neutral-500">\u2026max depth reached\u2026</div>'
                        sub_rows.append(
                            f'<tr class="border-t border-neutral-200 align-top">'
                            f'<td class="px-2 py-1 whitespace-nowrap align-top">{i}</td>'
                            f'<td class="px-2 py-1 align-top"><pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(summary)}</code></pre>{child_html}</td></tr>'
                        )
                    inner += (
                        '<div class="w-full overflow-x-auto sm:overflow-visible">'
                        '<table class="w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden">'
                        '<thead class="bg-neutral-50/70"><tr><th class="px-2 py-1 text-left w-10">#</th>'
                        '<th class="px-2 py-1 text-left">Summary</th></tr></thead><tbody>'
                        + "".join(sub_rows) + '</tbody></table></div>'
                    )
            else:
                child = value
                ident_parts = []
                for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
                    if k in child.__mapper__.c:
                        v = getattr(child, k, "")
                        ident_parts.append(f"{k}={v}")
                summary = " \u2022 ".join(ident_parts) if ident_parts else str(child)
                inner = f'<pre class="whitespace-pre-wrap break-words break-all text-xs mb-2"><code>{esc(summary)}</code></pre>'
                if depth < max_depth:
                    inner += f'<div class="pl-3 border-l border-neutral-200">{_render_model(child, depth + 1, max_depth)}</div>'
                else:
                    inner += '<div class="text-xs text-neutral-500">\u2026max depth reached\u2026</div>'

            rel_parts.append(
                f'<div class="rounded-xl border border-neutral-200">'
                f'<div class="px-3 py-2 bg-neutral-50/70 text-sm font-medium">'
                f'Relationship: <span class="font-semibold">{esc(rel_name)}</span>'
                f' <span class="ml-2 text-xs text-neutral-500">{cardinality} \u2192 {esc(cls_name)}{loaded_label}</span></div>'
                f'<div class="p-3 text-sm">{inner}</div></div>'
            )
        if rel_parts:
            parts.append('<div class="space-y-3">' + "".join(rel_parts) + '</div>')
        return '<div class="space-y-4">' + "".join(parts) + '</div>'

    html = (
        f'<div class="px-4 py-8">'
        f'<div class="mb-6 text-sm text-neutral-500">Model: <code>Post</code> \u2022 Table: <code>{esc(tablename)}</code></div>'
        f'{_render_model(original_post, 0, 2)}</div>'
    )
    return _raw_html_sx(html)


async def _h_post_preview_content(slug=None, **kw):
    await _ensure_post_data(slug)
    from quart import g
    from shared.services.registry import services
    from shared.sx.helpers import render_to_sx
    from shared.sx.parser import SxExpr, serialize as sx_serialize

    preview = await services.blog_page.preview_data(g.s)

    sections: list[str] = []
    if preview.get("sx_pretty"):
        sections.append(await render_to_sx("blog-preview-section",
            title="S-Expression Source", content=SxExpr(preview["sx_pretty"])))
    if preview.get("json_pretty"):
        sections.append(await render_to_sx("blog-preview-section",
            title="Lexical JSON", content=SxExpr(preview["json_pretty"])))
    if preview.get("sx_rendered"):
        rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(preview["sx_rendered"])}))'
        sections.append(await render_to_sx("blog-preview-section",
            title="SX Rendered", content=SxExpr(rendered_sx)))
    if preview.get("lex_rendered"):
        rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(preview["lex_rendered"])}))'
        sections.append(await render_to_sx("blog-preview-section",
            title="Lexical Rendered", content=SxExpr(rendered_sx)))

    if not sections:
        return '(div :class "p-8 text-stone-500" "No content to preview.")'

    inner = " ".join(sections)
    return await render_to_sx("blog-preview-panel", sections=SxExpr(f"(<> {inner})"))


async def _h_post_entries_content(slug=None, **kw):
    await _ensure_post_data(slug)
    from quart import g, url_for as qurl
    from sqlalchemy import select
    from markupsafe import escape as esc
    from shared.models.calendars import Calendar
    from shared.utils import host_url
    from bp.post.services.entry_associations import get_post_entry_ids
    from bp.post.admin.routes import _render_associated_entries

    post_id = g.post_data["post"]["id"]
    post_slug = g.post_data["post"]["slug"]
    associated_entry_ids = await get_post_entry_ids(post_id)
    result = await g.s.execute(
        select(Calendar)
        .where(Calendar.deleted_at.is_(None))
        .order_by(Calendar.name.asc())
    )
    all_calendars = result.scalars().all()
    for calendar in all_calendars:
        await g.s.refresh(calendar, ["entries", "post"])

    # Associated entries list
    assoc_html = await _render_associated_entries(all_calendars, associated_entry_ids, post_slug)

    # Calendar browser
    cal_items: list[str] = []
    for cal in all_calendars:
        cal_post = getattr(cal, "post", None)
        cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
        cal_title = esc(getattr(cal_post, "title", "")) if cal_post else ""
        cal_name = esc(getattr(cal, "name", ""))
        cal_view_url = host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal.id))

        img_html = (
            f'<img src="{esc(cal_fi)}" alt="{cal_title}" class="w-12 h-12 rounded object-cover flex-shrink-0" />'
            if cal_fi else
            '<div class="w-12 h-12 rounded bg-stone-200 flex-shrink-0"></div>'
        )
        cal_items.append(
            f'<details class="border rounded-lg bg-white" data-toggle-group="calendar-browser">'
            f'<summary class="p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3">'
            f'{img_html}'
            f'<div class="flex-1">'
            f'<div class="font-semibold flex items-center gap-2"><i class="fa fa-calendar text-stone-500"></i> {cal_name}</div>'
            f'<div class="text-sm text-stone-600">{cal_title}</div>'
            f'</div></summary>'
            f'<div class="p-4 border-t" sx-get="{esc(cal_view_url)}" sx-trigger="intersect once" sx-swap="innerHTML">'
            f'<div class="text-sm text-stone-400">Loading calendar...</div>'
            f'</div></details>'
        )

    if cal_items:
        browser_html = (
            '<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3>'
            + "".join(cal_items) + '</div>'
        )
    else:
        browser_html = '<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3><div class="text-sm text-stone-400">No calendars found.</div></div>'

    return (
        _raw_html_sx('<div id="post-entries-content" class="space-y-6 p-4">')
        + assoc_html
        + _raw_html_sx(browser_html + '</div>')
    )


async def _h_post_settings_content(slug=None, **kw):
    await _ensure_post_data(slug)
    from quart import g, request
    from markupsafe import escape as esc
    from models.ghost_content import Post
    from sqlalchemy import select as sa_select
    from sqlalchemy.orm import selectinload
    from shared.browser.app.csrf import generate_csrf_token
    from bp.post.admin.routes import _post_to_edit_dict

    post_id = g.post_data["post"]["id"]
    post = (await g.s.execute(
        sa_select(Post)
        .where(Post.id == post_id)
        .options(selectinload(Post.tags))
    )).scalar_one_or_none()
    ghost_post = _post_to_edit_dict(post) if post else {}
    save_success = request.args.get("saved") == "1"
    csrf = generate_csrf_token()

    p = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
    is_page = p.get("is_page", False)
    gp = ghost_post

    def field_label(text, field_for=None):
        for_attr = f' for="{field_for}"' if field_for else ''
        return f'<label{for_attr} class="block text-[13px] font-medium text-stone-500 mb-[4px]">{esc(text)}</label>'

    input_cls = ('w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] '
                 'bg-white text-stone-700 placeholder:text-stone-300 '
                 'focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300')
    textarea_cls = input_cls + ' resize-y'

    def text_input(name, value='', placeholder='', input_type='text', maxlength=None):
        ml = f' maxlength="{maxlength}"' if maxlength else ''
        return (f'<input type="{input_type}" name="{name}" id="settings-{name}" value="{esc(value)}"'
                f' placeholder="{esc(placeholder)}"{ml} class="{input_cls}">')

    def textarea_input(name, value='', placeholder='', rows=3, maxlength=None):
        ml = f' maxlength="{maxlength}"' if maxlength else ''
        return (f'<textarea name="{name}" id="settings-{name}" rows="{rows}"'
                f' placeholder="{esc(placeholder)}"{ml} class="{textarea_cls}">{esc(value)}</textarea>')

    def checkbox_input(name, checked=False, label=''):
        chk = ' checked' if checked else ''
        return (f'<label class="inline-flex items-center gap-[8px] cursor-pointer">'
                f'<input type="checkbox" name="{name}" id="settings-{name}"{chk}'
                f' class="rounded border-stone-300 text-stone-600 focus:ring-stone-300">'
                f'<span class="text-[14px] text-stone-600">{esc(label)}</span></label>')

    def section(title, content, is_open=False):
        open_attr = ' open' if is_open else ''
        return (f'<details class="border border-stone-200 rounded-[8px] overflow-hidden"{open_attr}>'
                f'<summary class="px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors">{esc(title)}</summary>'
                f'<div class="px-[16px] py-[12px] space-y-[12px]">{content}</div></details>')

    # General section
    slug_placeholder = 'page-slug' if is_page else 'post-slug'
    pub_at = gp.get("published_at") or ""
    pub_at_val = pub_at[:16] if pub_at else ""
    vis = gp.get("visibility") or "public"
    vis_opts = "".join(
        f'<option value="{v}"{" selected" if vis == v else ""}>{l}</option>'
        for v, l in [("public", "Public"), ("members", "Members"), ("paid", "Paid")]
    )

    general = (
        f'<div>{field_label("Slug", "settings-slug")}{text_input("slug", gp.get("slug") or "", slug_placeholder)}</div>'
        f'<div>{field_label("Published at", "settings-published_at")}'
        f'<input type="datetime-local" name="published_at" id="settings-published_at" value="{esc(pub_at_val)}" class="{input_cls}"></div>'
        f'<div>{checkbox_input("featured", gp.get("featured"), "Featured page" if is_page else "Featured post")}</div>'
        f'<div>{field_label("Visibility", "settings-visibility")}'
        f'<select name="visibility" id="settings-visibility" class="{input_cls}">{vis_opts}</select></div>'
        f'<div>{checkbox_input("email_only", gp.get("email_only"), "Email only")}</div>'
    )

    # Tags
    tags = gp.get("tags") or []
    if tags:
        tag_names = ", ".join(getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t)) for t in tags)
    else:
        tag_names = ""
    tags_sec = (
        f'<div>{field_label("Tags (comma-separated)", "settings-tags")}'
        f'{text_input("tags", tag_names, "news, updates, featured")}'
        f'<p class="text-[12px] text-stone-400 mt-[4px]">Unknown tags will be created automatically.</p></div>'
    )

    fi_sec = f'<div>{field_label("Alt text", "settings-feature_image_alt")}{text_input("feature_image_alt", gp.get("feature_image_alt") or "", "Describe the feature image")}</div>'

    seo_sec = (
        f'<div>{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}'
        f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 70 characters. Max: 300.</p></div>'
        f'<div>{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}'
        f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 156 characters.</p></div>'
        f'<div>{field_label("Canonical URL", "settings-canonical_url")}{text_input("canonical_url", gp.get("canonical_url") or "", "https://example.com/original-post", input_type="url")}</div>'
    )

    og_sec = (
        f'<div>{field_label("OG title", "settings-og_title")}{text_input("og_title", gp.get("og_title") or "")}</div>'
        f'<div>{field_label("OG description", "settings-og_description")}{textarea_input("og_description", gp.get("og_description") or "", rows=2)}</div>'
        f'<div>{field_label("OG image URL", "settings-og_image")}{text_input("og_image", gp.get("og_image") or "", "https://...", input_type="url")}</div>'
    )

    tw_sec = (
        f'<div>{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}</div>'
        f'<div>{field_label("Twitter description", "settings-twitter_description")}{textarea_input("twitter_description", gp.get("twitter_description") or "", rows=2)}</div>'
        f'<div>{field_label("Twitter image URL", "settings-twitter_image")}{text_input("twitter_image", gp.get("twitter_image") or "", "https://...", input_type="url")}</div>'
    )

    tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs'
    adv_sec = f'<div>{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}</div>'

    sections = (
        section("General", general, is_open=True)
        + section("Tags", tags_sec)
        + section("Feature Image", fi_sec)
        + section("SEO / Meta", seo_sec)
        + section("Facebook / OpenGraph", og_sec)
        + section("X / Twitter", tw_sec)
        + section("Advanced", adv_sec)
    )

    saved_html = '<span class="text-[14px] text-green-600">Saved.</span>' if save_success else ''

    html = (
        f'<form method="post" class="max-w-[640px] mx-auto pb-[48px] px-[16px]">'
        f'<input type="hidden" name="csrf_token" value="{csrf}">'
        f'<input type="hidden" name="updated_at" value="{esc(gp.get("updated_at") or "")}">'
        f'<div class="space-y-[12px] mt-[16px]">{sections}</div>'
        f'<div class="flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200">'
        f'<button type="submit" class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer">Save settings</button>'
        f'{saved_html}</div></form>'
    )
    return _raw_html_sx(html)


async def _h_post_edit_content(slug=None, **kw):
    await _ensure_post_data(slug)
    import os
    from quart import g, request as qrequest, url_for as qurl, current_app
    from models.ghost_content import Post
    from sqlalchemy import select as sa_select
    from sqlalchemy.orm import selectinload
    from shared.infrastructure.data_client import fetch_data
    from shared.browser.app.csrf import generate_csrf_token
    from shared.sx.helpers import render_to_sx
    from shared.sx.parser import SxExpr, serialize as sx_serialize
    from bp.post.admin.routes import _post_to_edit_dict

    post_id = g.post_data["post"]["id"]
    db_post = (await g.s.execute(
        sa_select(Post)
        .where(Post.id == post_id)
        .options(selectinload(Post.tags))
    )).scalar_one_or_none()
    ghost_post = _post_to_edit_dict(db_post) if db_post else {}
    save_success = qrequest.args.get("saved") == "1"
    save_error = qrequest.args.get("error", "")
    raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
    from types import SimpleNamespace
    newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]

    csrf = generate_csrf_token()
    asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
    editor_css = asset_url_fn("scripts/editor.css")
    editor_js = asset_url_fn("scripts/editor.js")
    sx_editor_js = asset_url_fn("scripts/sx-editor.js")

    upload_image_url = qurl("blog.editor_api.upload_image")
    upload_media_url = qurl("blog.editor_api.upload_media")
    upload_file_url = qurl("blog.editor_api.upload_file")
    oembed_url = qurl("blog.editor_api.oembed_proxy")
    snippets_url = qurl("blog.editor_api.list_snippets")
    unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")

    post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
    is_page = post.get("is_page", False)

    feature_image = ghost_post.get("feature_image") or ""
    feature_image_caption = ghost_post.get("feature_image_caption") or ""
    title_val = ghost_post.get("title") or ""
    excerpt_val = ghost_post.get("custom_excerpt") or ""
    updated_at = ghost_post.get("updated_at") or ""
    status = ghost_post.get("status") or "draft"
    lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
    sx_content = ghost_post.get("sx_content") or ""
    has_sx = bool(sx_content)

    already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
    email_obj = ghost_post.get("email")
    if email_obj and not isinstance(email_obj, dict):
        already_emailed = bool(getattr(email_obj, "status", None))

    title_placeholder = "Page title..." if is_page else "Post title..."

    # Newsletter options as SX fragment
    nl_parts = ['(option :value "" "Select newsletter\u2026")']
    for nl in newsletters:
        nl_slug = sx_serialize(getattr(nl, "slug", ""))
        nl_name = sx_serialize(getattr(nl, "name", ""))
        nl_parts.append(f"(option :value {nl_slug} {nl_name})")
    nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")")

    # Footer extra badges as SX fragment
    badge_parts: list[str] = []
    if save_success:
        badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")')
    publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None
    if publish_requested:
        badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")')
    if post.get("publish_requested"):
        badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")')
    if already_emailed:
        nl_name = ""
        newsletter = ghost_post.get("newsletter")
        if newsletter:
            nl_name = getattr(newsletter, "name", "") if not isinstance(newsletter, dict) else newsletter.get("name", "")
        suffix = f" to {nl_name}" if nl_name else ""
        badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")')
    footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None

    parts: list[str] = []

    if save_error:
        parts.append(await render_to_sx("blog-editor-error", error=save_error))

    parts.append(await render_to_sx("blog-editor-edit-form",
        csrf=csrf,
        updated_at=str(updated_at),
        title_val=title_val,
        excerpt_val=excerpt_val,
        feature_image=feature_image,
        feature_image_caption=feature_image_caption,
        sx_content_val=sx_content,
        lexical_json=lexical_json,
        has_sx=has_sx,
        title_placeholder=title_placeholder,
        status=status,
        already_emailed=already_emailed,
        newsletter_options=nl_opts_sx,
        footer_extra=footer_extra_sx,
    ))

    parts.append(await render_to_sx("blog-editor-publish-js", already_emailed=already_emailed))
    parts.append(await render_to_sx("blog-editor-styles", css_href=editor_css))
    parts.append(await render_to_sx("sx-editor-styles"))

    init_js = (
        '(function() {'
        "  function applyEditorFontSize() {"
        "    document.documentElement.style.fontSize = '62.5%';"
        "    document.body.style.fontSize = '1.6rem';"
        '  }'
        "  function restoreDefaultFontSize() {"
        "    document.documentElement.style.fontSize = '';"
        "    document.body.style.fontSize = '';"
        '  }'
        '  applyEditorFontSize();'
        "  document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {"
        "    if (e.detail.target && e.detail.target.id === 'main-panel') {"
        '      restoreDefaultFontSize();'
        "      document.body.removeEventListener('htmx:beforeSwap', cleanup);"
        '    }'
        '  });'
        '  function init() {'
        "    var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;"
        f"    var uploadUrl = '{upload_image_url}';"
        '    var uploadUrls = {'
        '      image: uploadUrl,'
        f"      media: '{upload_media_url}',"
        f"      file: '{upload_file_url}',"
        '    };'
        "    var fileInput = document.getElementById('feature-image-file');"
        "    var addBtn = document.getElementById('feature-image-add-btn');"
        "    var deleteBtn = document.getElementById('feature-image-delete-btn');"
        "    var preview = document.getElementById('feature-image-preview');"
        "    var emptyState = document.getElementById('feature-image-empty');"
        "    var filledState = document.getElementById('feature-image-filled');"
        "    var hiddenUrl = document.getElementById('feature-image-input');"
        "    var hiddenCaption = document.getElementById('feature-image-caption-input');"
        "    var captionInput = document.getElementById('feature-image-caption');"
        "    var uploading = document.getElementById('feature-image-uploading');"
        '    function showFilled(url) {'
        '      preview.src = url; hiddenUrl.value = url;'
        "      emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');"
        '    }'
        '    function showEmpty() {'
        "      preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';"
        "      emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');"
        '    }'
        '    function uploadFile(file) {'
        "      emptyState.classList.add('hidden'); uploading.classList.remove('hidden');"
        "      var fd = new FormData(); fd.append('file', file);"
        "      fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })"
        "      .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })"
        '      .then(function(data) {'
        '        var url = data.images && data.images[0] && data.images[0].url;'
        "        if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }"
        '      })'
        '      .catch(function(e) { showEmpty(); alert(e.message); });'
        '    }'
        "    addBtn.addEventListener('click', function() { fileInput.click(); });"
        "    preview.addEventListener('click', function() { fileInput.click(); });"
        "    deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });"
        "    fileInput.addEventListener('change', function() {"
        '      if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = \'\'; }'
        '    });'
        "    captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });"
        "    var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');"
        "    function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }"
        "    excerpt.addEventListener('input', autoResize); autoResize();"
        '    var dataEl = document.getElementById(\'lexical-initial-data\');'
        '    var initialJson = dataEl ? dataEl.textContent.trim() : null;'
        '    if (initialJson) { var hidden = document.getElementById(\'lexical-json-input\'); if (hidden) hidden.value = initialJson; }'
        "    window.mountEditor('lexical-editor', {"
        '      initialJson: initialJson,'
        '      csrfToken: csrfToken,'
        '      uploadUrls: uploadUrls,'
        f"      oembedUrl: '{oembed_url}',"
        f"      unsplashApiKey: '{unsplash_key}',"
        f"      snippetsUrl: '{snippets_url}',"
        '    });'
        "    if (typeof SxEditor !== 'undefined') {"
        "      SxEditor.mount('sx-editor', {"
        "        initialSx: (document.getElementById('sx-content-input') || {}).value || null,"
        '        csrfToken: csrfToken,'
        '        uploadUrls: uploadUrls,'
        f"        oembedUrl: '{oembed_url}',"
        '        onChange: function(sx) {'
        "          document.getElementById('sx-content-input').value = sx;"
        '        }'
        '      });'
        '    }'
        "    document.addEventListener('keydown', function(e) {"
        "      if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
        "        e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();"
        '      }'
        '    });'
        '  }'
        "  if (typeof window.mountEditor === 'function') { init(); }"
        '  else { var _t = setInterval(function() {'
        "    if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }"
        '  }, 50); }'
        '})();'
    )
    parts.append(await render_to_sx("blog-editor-scripts",
                         js_src=editor_js,
                         sx_editor_js_src=sx_editor_js,
                         init_js=init_js))

    return await render_to_sx("blog-editor-panel",
        parts=SxExpr("(<> " + " ".join(parts) + ")"))


