"""
Shared helper functions for s-expression page rendering.

These are used by per-service sxc/pages modules to build common
page elements (headers, search, etc.) from template context.
"""
from __future__ import annotations

import hashlib
import os
from pathlib import Path
from typing import Any

from markupsafe import escape

from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SxExpr


def _sx_fragment(*parts: str) -> SxExpr:
    """Wrap pre-rendered SX wire format strings in a fragment.

    Infrastructure utility for composing already-serialized SX strings.
    NOT for building SX from Python data — use sx_call() or _render_to_sx().
    """
    joined = " ".join(p for p in parts if p)
    return SxExpr(f"(<> {joined})") if joined else SxExpr("")


def call_url(ctx: dict, key: str, path: str = "/") -> str:
    """Call a URL helper from context (e.g., blog_url, account_url)."""
    fn = ctx.get(key)
    if callable(fn):
        return fn(path)
    return str(fn or "") + path


def get_asset_url(ctx: dict) -> str:
    """Extract the asset URL base from context."""
    au = ctx.get("asset_url")
    if callable(au):
        result = au("")
        return result.rsplit("/", 1)[0] if "/" in result else result
    return au or ""



# ---------------------------------------------------------------------------
# Sx-native helper functions — return sx source (not HTML)
# ---------------------------------------------------------------------------

def _as_sx(val: Any) -> SxExpr | None:
    """Coerce a fragment value to SxExpr.

    If *val* is already a ``SxExpr`` (from a ``text/sx`` fragment),
    return it as-is.  If it's a non-empty string (HTML from a
    ``text/html`` fragment), wrap it in ``~rich-text``.  Otherwise
    return ``None``.
    """
    if not val:
        return None
    if isinstance(val, SxExpr):
        return val if val.source else None
    html = str(val)
    return sx_call("rich-text", html=html)


async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
    """Build the root header row as sx wire format."""
    rights = ctx.get("rights") or {}
    is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
    settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
    return await _render_to_sx("shared:layout/header-row-sx",
        cart_mini=_as_sx(ctx.get("cart_mini")),
        blog_url=call_url(ctx, "blog_url", ""),
        site_title=ctx.get("base_title", ""),
        app_label=ctx.get("app_label", ""),
        nav_tree=_as_sx(ctx.get("nav_tree")),
        auth_menu=_as_sx(ctx.get("auth_menu")),
        nav_panel=_as_sx(ctx.get("nav_panel")),
        settings_url=settings_url,
        is_admin=is_admin,
        oob=oob,
    )


def mobile_menu_sx(*sections: str) -> SxExpr:
    """Assemble mobile menu from pre-built sections (deepest first)."""
    parts = [s for s in sections if s]
    return _sx_fragment(*parts) if parts else SxExpr("")


async def mobile_root_nav_sx(ctx: dict) -> str:
    """Root-level mobile nav via ~shared:layout/mobile-root-nav component."""
    nav_tree = ctx.get("nav_tree") or ""
    auth_menu = ctx.get("auth_menu") or ""
    if not nav_tree and not auth_menu:
        return ""
    return await _render_to_sx("shared:layout/mobile-root-nav",
        nav_tree=_as_sx(nav_tree),
        auth_menu=_as_sx(auth_menu),
    )


# ---------------------------------------------------------------------------
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
# ---------------------------------------------------------------------------

async def _post_nav_items_sx(ctx: dict) -> SxExpr:
    """Build post-level nav items (container_nav + admin cog). Shared by
    ``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
    post = ctx.get("post") or {}
    slug = post.get("slug", "")
    if not slug:
        return SxExpr("")
    parts: list[str] = []
    page_cart_count = ctx.get("page_cart_count", 0)
    if page_cart_count and page_cart_count > 0:
        cart_href = call_url(ctx, "cart_url", f"/{slug}/")
        parts.append(await _render_to_sx("shared:layout/page-cart-badge", href=cart_href,
                             count=str(page_cart_count)))

    container_nav = str(ctx.get("container_nav") or "").strip()
    # Skip empty fragment wrappers like "(<>  )"
    if container_nav and container_nav.replace("(<>", "").replace(")", "").strip():
        parts.append(await _render_to_sx("shared:layout/container-nav-wrapper",
                             content=SxExpr(container_nav)))

    # Admin cog
    admin_nav = ctx.get("post_admin_nav")
    if not admin_nav:
        rights = ctx.get("rights") or {}
        has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
        if has_admin and slug:
            from quart import request
            admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
            is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
            admin_nav = await _render_to_sx("shared:layout/admin-cog-button",
                                 href=admin_href,
                                 is_admin_page=is_admin_page or None)
    if admin_nav:
        parts.append(admin_nav)
    return _sx_fragment(*parts) if parts else SxExpr("")


async def _post_admin_nav_items_sx(ctx: dict, slug: str,
                             selected: str = "") -> SxExpr:
    """Build post-admin nav items (calendars, markets, etc.). Shared by
    ``post_admin_header_sx`` (desktop) and mobile menu."""
    select_colours = ctx.get("select_colours", "")
    parts: list[str] = []
    items = [
        ("events_url", f"/{slug}/admin/", "calendars"),
        ("market_url", f"/{slug}/admin/", "markets"),
        ("cart_url",   f"/{slug}/admin/payments/", "payments"),
        ("blog_url",   f"/{slug}/admin/entries/", "entries"),
        ("blog_url",   f"/{slug}/admin/data/", "data"),
        ("blog_url",   f"/{slug}/admin/preview/", "preview"),
        ("blog_url",   f"/{slug}/admin/edit/", "edit"),
        ("blog_url",   f"/{slug}/admin/settings/", "settings"),
    ]
    for url_key, path, label in items:
        url_fn = ctx.get(url_key)
        if not callable(url_fn):
            continue
        href = url_fn(path)
        is_sel = label == selected
        parts.append(await _render_to_sx("shared:layout/nav-link", href=href, label=label,
                             select_colours=select_colours,
                             is_selected=is_sel or None))
    return _sx_fragment(*parts) if parts else SxExpr("")


# ---------------------------------------------------------------------------
# Mobile menu section builders — wrap shared nav items for hamburger panel
# ---------------------------------------------------------------------------

async def post_mobile_nav_sx(ctx: dict) -> str:
    """Post-level mobile menu section."""
    nav = await _post_nav_items_sx(ctx)
    if not nav:
        return ""
    post = ctx.get("post") or {}
    slug = post.get("slug", "")
    title = (post.get("title") or slug)[:40]
    return await _render_to_sx("shared:layout/mobile-menu-section",
        label=title,
        href=call_url(ctx, "blog_url", f"/{slug}/"),
        level=1,
        items=nav,
    )



async def search_mobile_sx(ctx: dict) -> str:
    """Build mobile search input as sx wire format."""
    return await _render_to_sx("shared:controls/search-mobile",
        current_local_href=ctx.get("current_local_href", "/"),
        search=ctx.get("search", ""),
        search_count=ctx.get("search_count", ""),
        hx_select=ctx.get("hx_select", "#main-panel"),
        search_headers_mobile=SEARCH_HEADERS_MOBILE,
    )


async def search_desktop_sx(ctx: dict) -> str:
    """Build desktop search input as sx wire format."""
    return await _render_to_sx("shared:controls/search-desktop",
        current_local_href=ctx.get("current_local_href", "/"),
        search=ctx.get("search", ""),
        search_count=ctx.get("search_count", ""),
        hx_select=ctx.get("hx_select", "#main-panel"),
        search_headers_desktop=SEARCH_HEADERS_DESKTOP,
    )


async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
    """Build the post-level header row as sx wire format."""
    post = ctx.get("post") or {}
    slug = post.get("slug", "")
    if not slug:
        return ""
    title = (post.get("title") or "")[:160]
    feature_image = post.get("feature_image")

    label_sx = await _render_to_sx("shared:layout/post-label", feature_image=feature_image, title=title)
    nav_sx = await _post_nav_items_sx(ctx) or None
    link_href = call_url(ctx, "blog_url", f"/{slug}/")

    return await _render_to_sx("shared:layout/menu-row-sx",
        id="post-row", level=1,
        link_href=link_href,
        link_label_content=label_sx,
        nav=nav_sx,
        child_id="post-header-child",
        child=SxExpr(child) if child else None,
        oob=oob, external=True,
    )


async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
                            selected: str = "", admin_href: str = "") -> str:
    """Post admin header row as sx wire format."""
    # Label
    label_sx = await _render_to_sx("shared:layout/post-admin-label",
                        selected=str(escape(selected)) if selected else None)

    nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None

    if not admin_href:
        blog_fn = ctx.get("blog_url")
        admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"

    return await _render_to_sx("shared:layout/menu-row-sx",
        id="post-admin-row", level=2,
        link_href=admin_href,
        link_label_content=label_sx,
        nav=nav_sx,
        child_id="post-admin-header-child", oob=oob,
    )


async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
    """Wrap a header row sx in an OOB swap.

    child_id is accepted for call-site compatibility but no longer used —
    the child placeholder is created by ~shared:layout/menu-row-sx itself.
    """
    return await _render_to_sx("shared:layout/oob-header-sx",
        parent_id=parent_id,
        row=SxExpr(row_sx),
    )


async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
    """Wrap inner sx in a header-child div."""
    return await _render_to_sx("shared:layout/header-child-sx",
        id=id, inner=_sx_fragment(inner_sx),
    )


async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
                   content: str = "", menu: str = "") -> str:
    """Build OOB response as sx wire format."""
    return await _render_to_sx("shared:layout/oob-sx",
        oobs=_sx_fragment(oobs) if oobs else None,
        filter=SxExpr(filter) if filter else None,
        aside=SxExpr(aside) if aside else None,
        menu=SxExpr(menu) if menu else None,
        content=SxExpr(content) if content else None,
    )


async def full_page_sx(ctx: dict, *, header_rows: str,
                    filter: str = "", aside: str = "",
                    content: str = "", menu: str = "",
                    meta_html: str = "", meta: str = "",
                    head_scripts: list[str] | None = None,
                    inline_css: str | None = None,
                    inline_head_js: str | None = None,
                    init_sx: str | None = None,
                    body_scripts: list[str] | None = None) -> str:
    """Build a full page using sx_page() with ~app-body.

    meta_html: raw HTML injected into the <head> shell (legacy).
    meta: sx source for meta tags — auto-hoisted to <head> by sx.js.
    """
    # Auto-generate mobile nav from context when no menu provided
    if not menu:
        menu = await mobile_root_nav_sx(ctx)
    body_sx = await _render_to_sx("shared:layout/app-body",
        header_rows=_sx_fragment(header_rows) if header_rows else None,
        filter=SxExpr(filter) if filter else None,
        aside=SxExpr(aside) if aside else None,
        menu=SxExpr(menu) if menu else None,
        content=SxExpr(content) if content else None,
    )
    if meta:
        # Wrap body + meta in a fragment so sx.js renders both;
        # auto-hoist moves meta/title/link elements to <head>.
        body_sx = _sx_fragment(meta, body_sx)
    return await sx_page(ctx, body_sx, meta_html=meta_html,
                         head_scripts=head_scripts, inline_css=inline_css,
                         inline_head_js=inline_head_js, init_sx=init_sx,
                         body_scripts=body_scripts)


def _build_component_ast(__name: str, **kwargs: Any) -> list:
    """Build an AST list for a component call from Python kwargs.

    Returns e.g. [Symbol("~card"), Keyword("title"), "hello", Keyword("count"), 3]
    No SX string generation — values stay as native Python objects.
    """
    from .types import Symbol, Keyword, NIL
    comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
    ast: list = [comp_sym]
    for key, val in kwargs.items():
        kebab = key.replace("_", "-")
        ast.append(Keyword(kebab))
        if val is None:
            ast.append(NIL)
        elif isinstance(val, SxExpr):
            # SxExpr values need to be parsed into AST
            from .parser import parse
            if not val.source:
                ast.append(NIL)
            else:
                ast.append(parse(val.source))
        else:
            ast.append(val)
    return ast


async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) -> str:
    """Like ``_render_to_sx`` but merges *extra_env* into the evaluation
    environment before eval.  Used by ``register_sx_layout`` so .sx
    defcomps can read ctx values as free variables.

    Uses ``async_eval_slot_to_sx`` (not ``async_eval_to_sx``) so the
    top-level component body is expanded server-side — free variables
    from *extra_env* are resolved during expansion rather than being
    serialized as unresolved symbols for the client.

    **Private** — service code should use ``sx_call()`` or defmacros instead.
    """
    from .jinja_bridge import get_component_env, _get_request_context
    import os
    from .types import Symbol, Keyword, NIL as _NIL

    # Build AST with extra_env entries as keyword args so _aser_component
    # binds them as params (otherwise it defaults all params to NIL).
    comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
    ast: list = [comp_sym]
    for k, v in extra_env.items():
        ast.append(Keyword(k))
        ast.append(v if v is not None else _NIL)
    for k, v in kwargs.items():
        ast.append(Keyword(k.replace("_", "-")))
        ast.append(v if v is not None else _NIL)

    if os.environ.get("SX_USE_OCAML") == "1":
        from .ocaml_bridge import get_bridge
        from .parser import serialize
        bridge = await get_bridge()
        sx_text = serialize(ast)
        ocaml_ctx = {"_helper_service": _get_request_context().get("_helper_service", "")} if isinstance(_get_request_context(), dict) else {}
        return SxExpr(await bridge.aser_slot(sx_text, ctx=ocaml_ctx))

    from .async_eval import async_eval_slot_to_sx

    env = dict(get_component_env())
    env.update(extra_env)
    ctx = _get_request_context()
    return SxExpr(await async_eval_slot_to_sx(ast, env, ctx))


async def _render_to_sx(__name: str, **kwargs: Any) -> str:
    """Call a defcomp and get SX wire format back. No SX string literals.

    Builds an AST from Python values and evaluates it through the SX
    evaluator, which resolves IO primitives and serializes component/tag
    calls as SX wire format.

    **Private** — service code should use ``sx_call()`` or defmacros instead.
    Only infrastructure code (helpers.py, layouts.py) should call this.
    """
    from .jinja_bridge import get_component_env, _get_request_context
    import os

    ast = _build_component_ast(__name, **kwargs)

    if os.environ.get("SX_USE_OCAML") == "1":
        from .ocaml_bridge import get_bridge
        from .parser import serialize
        bridge = await get_bridge()
        sx_text = serialize(ast)
        # aser_slot (not aser) — layout wrappers contain re-parsed
        # content from earlier aser_slot calls.  Regular aser fails on
        # symbols like `title` that were bound during the earlier expansion.
        return SxExpr(await bridge.aser_slot(sx_text))

    from .async_eval import async_eval_to_sx

    env = dict(get_component_env())
    ctx = _get_request_context()
    return SxExpr(await async_eval_to_sx(ast, env, ctx))


# Backwards-compat alias — layout infrastructure still imports this.
# Will be removed once all layouts use register_sx_layout().
render_to_sx_with_env = _render_to_sx_with_env


async def render_to_html(__name: str, **kwargs: Any) -> str:
    """Call a defcomp and get HTML back. No SX string literals.

    Same as render_to_sx() but produces HTML output instead of SX wire
    format. Used by route renders that need HTML (full pages, fragments).

    Routes through the OCaml bridge (render mode) which handles component
    parameter binding, scope primitives, and all evaluation.
    """
    import os

    ast = _build_component_ast(__name, **kwargs)

    if os.environ.get("SX_USE_OCAML") == "1":
        from .ocaml_bridge import get_bridge
        from .parser import serialize
        bridge = await get_bridge()
        sx_text = serialize(ast)
        return await bridge.render(sx_text)

    # Fallback: Python async_eval (requires working evaluator)
    from .jinja_bridge import get_component_env, _get_request_context
    from .async_eval import async_render
    env = dict(get_component_env())
    ctx = _get_request_context()
    return await async_render(ast, env, ctx)


def sx_call(component_name: str, **kwargs: Any) -> str:
    """Build an s-expression component call string from Python kwargs.

    Converts snake_case to kebab-case automatically::

        sx_call("test-row", nodeid="foo", outcome="passed")
        # => '(~test-row :nodeid "foo" :outcome "passed")'

    Values are serialized: strings are quoted, None becomes nil,
    bools become true/false, numbers stay as-is.
    List values use ``(list ...)`` so the client gets an iterable array
    rather than a rendered fragment.
    """
    from .parser import serialize, SxExpr
    name = component_name if component_name.startswith("~") else f"~{component_name}"
    parts = [name]
    for key, val in kwargs.items():
        parts.append(f":{key.replace('_', '-')}")
        if isinstance(val, list):
            items = [serialize(v) for v in val if v is not None]
            if not items:
                parts.append("nil")
            else:
                parts.append("(list " + " ".join(items) + ")")
        else:
            parts.append(serialize(val))
    return SxExpr("(" + " ".join(parts) + ")")



def components_for_request(source: str = "",
                           extra_names: set[str] | None = None) -> str:
    """Return defcomp/defmacro source for definitions the client doesn't have yet.

    Reads the ``SX-Components`` header (comma-separated component names
    like ``~card,~nav-item``) and returns only the definitions the client
    is missing.  If *source* is provided, only sends components needed
    for that source (plus transitive deps).  If the header is absent,
    returns all needed defs.

    *extra_names* — additional component names (``~foo``) to include
    beyond what *source* references.  Used by ``execute_page`` to send
    components the page's content expression needs for client-side routing.
    """
    from quart import request
    from .jinja_bridge import _COMPONENT_ENV
    from .deps import components_needed
    from .types import Component, Island, Macro
    from .parser import serialize

    # Determine which components the page needs
    if source:
        needed = components_needed(source, _COMPONENT_ENV)
    else:
        needed = None  # all

    # Merge in extra names (e.g. from page content expression deps)
    if extra_names and needed is not None:
        needed = needed | extra_names
    elif extra_names:
        needed = extra_names

    # Check hash first (new): if client hash matches current, skip all defs.
    # Fall back to legacy name list (SX-Components) for backward compat.
    comp_hash_header = request.headers.get("SX-Components-Hash", "")
    if comp_hash_header:
        from .jinja_bridge import components_for_page
        _, current_hash = components_for_page("", service=None)
        if comp_hash_header == current_hash:
            return ""  # client has everything
        loaded = set()  # hash mismatch — send all needed
    else:
        loaded_raw = request.headers.get("SX-Components", "")
        loaded = set(loaded_raw.split(",")) if loaded_raw else set()

    parts = []
    for key, val in _COMPONENT_ENV.items():
        if isinstance(val, Island):
            comp_name = f"~{val.name}"
            if needed is not None and comp_name not in needed and key not in needed:
                continue
            if comp_name in loaded or val.name in loaded:
                continue
            param_strs = ["&key"] + list(val.params)
            if val.has_children:
                param_strs.extend(["&rest", "children"])
            params_sx = "(" + " ".join(param_strs) + ")"
            body_sx = serialize(val.body, pretty=True)
            parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
        elif isinstance(val, Component):
            comp_name = f"~{val.name}"
            # Skip if not needed for this page
            if needed is not None and comp_name not in needed and key not in needed:
                continue
            # Skip components the client already has
            if comp_name in loaded or val.name in loaded:
                continue
            # Reconstruct defcomp source
            param_strs = ["&key"] + list(val.params)
            if val.has_children:
                param_strs.extend(["&rest", "children"])
            params_sx = "(" + " ".join(param_strs) + ")"
            body_sx = serialize(val.body, indent=1, pretty=True)
            head = f"(defcomp ~{val.name} {params_sx}"
            if "\n" in body_sx:
                parts.append(f"{head}\n  {body_sx})")
            else:
                parts.append(f"{head} {body_sx})")
        elif isinstance(val, Macro):
            if val.name in loaded:
                continue
            param_strs = list(val.params)
            if val.rest_param:
                param_strs.extend(["&rest", val.rest_param])
            params_sx = "(" + " ".join(param_strs) + ")"
            body_sx = serialize(val.body, indent=1, pretty=True)
            head = f"(defmacro {val.name} {params_sx}"
            if "\n" in body_sx:
                parts.append(f"{head}\n  {body_sx})")
            else:
                parts.append(f"{head} {body_sx})")
    return "\n".join(parts)


def sx_response(source: str, status: int = 200,
                   headers: dict | None = None,
                   extra_component_names: set[str] | None = None):
    """Return an s-expression wire-format response.

    Takes a raw sx string::

        return sx_response('(~test-row :nodeid "foo")')

    For SX requests, missing component definitions are prepended as a
    ``<script type="text/sx" data-components>`` block so the client
    can process them before rendering OOB content.

    *extra_component_names* — additional component names to include beyond
    what *source* references.  Used by defpage to send components the page's
    content expression needs for client-side routing.
    """
    from quart import request, Response

    body = source
    # Validate the sx source parses as a single expression
    try:
        from .parser import parse as _parse_check
        _parse_check(source)
    except Exception as _e:
        import logging
        logging.getLogger("sx").error("sx_response parse error: %s\nSource (first 500): %s", _e, source[:500])

    # For SX requests, prepend missing component definitions
    comp_defs = ""
    if request.headers.get("SX-Request"):
        comp_defs = components_for_request(source, extra_names=extra_component_names)
        if comp_defs:
            body = (f'<script type="text/sx" data-components>'
                    f'{comp_defs}</script>\n{body}')

    # On-demand CSS: scan source for classes, send only new rules
    from .css_registry import scan_classes_from_sx, lookup_rules, registry_loaded, lookup_css_hash, store_css_hash
    new_classes: set[str] = set()
    cumulative_classes: set[str] = set()
    if registry_loaded():
        new_classes = scan_classes_from_sx(source)
        if comp_defs:
            # Scan only the component definitions actually being sent
            new_classes.update(scan_classes_from_sx(comp_defs))

        # Resolve known classes from SX-Css header (hash or full list)
        known_classes: set[str] = set()
        known_raw = request.headers.get("SX-Css", "")
        if known_raw:
            if len(known_raw) <= 16:
                # Treat as hash
                looked_up = lookup_css_hash(known_raw)
                if looked_up is not None:
                    known_classes = looked_up
                else:
                    # Cache miss — send all classes (safe fallback)
                    known_classes = set()
            else:
                known_classes = set(known_raw.split(","))

        cumulative_classes = known_classes | new_classes
        new_classes -= known_classes

        if new_classes:
            new_rules = lookup_rules(new_classes)
            if new_rules:
                body = f'<style data-sx-css>{new_rules}</style>\n{body}'

    # Dev mode: pretty-print sx source for readable Network tab responses
    if _is_dev_mode():
        body = _pretty_print_sx_body(body)

    resp = Response(body, status=status, content_type="text/sx")
    if new_classes:
        resp.headers["SX-Css-Add"] = ",".join(sorted(new_classes))
    if cumulative_classes:
        resp.headers["SX-Css-Hash"] = store_css_hash(cumulative_classes)
    if headers:
        for k, v in headers.items():
            resp.headers[k] = v
    return resp



# ---------------------------------------------------------------------------
# Sx wire-format full page shell
# ---------------------------------------------------------------------------
# The page shell is defined as ~shared:shell/sx-page-shell in shared/sx/templates/shell.sx
# and rendered via render_to_html. No HTML string templates in Python.


def _build_pages_sx(service: str) -> str:
    """Build SX page registry for client-side routing.

    Returns SX dict literals (one per page) parseable by the client's
    ``parse`` function.  Each dict has keys: name, path, auth, has-data,
    content, closure, deps.
    """
    import logging
    _log = logging.getLogger("sx.pages")
    from .pages import get_all_pages
    from .parser import serialize as sx_serialize
    from .deps import components_needed
    from .jinja_bridge import _COMPONENT_ENV

    pages = get_all_pages(service)
    _log.debug("_build_pages_sx(%s): %d pages in registry", service, len(pages))
    if not pages:
        _log.warning("_build_pages_sx(%s): no pages found — page registry will be empty", service)
        return ""

    entries = []
    for page_def in pages.values():
        content_src = ""
        if page_def.content_expr is not None:
            content_src = sx_serialize(page_def.content_expr)

        auth = page_def.auth if isinstance(page_def.auth, str) else "custom"
        has_data = "true" if page_def.data_expr is not None else "false"

        # Component deps needed to render this page client-side
        # Compute for all pages (including :data pages) — the client
        # renders both pure and data pages after fetching data
        deps: set[str] = set()
        if content_src:
            deps = components_needed(content_src, _COMPONENT_ENV)
        deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")"

        # Collect IO primitive names referenced by dep components
        from .types import Component as _Comp
        io_deps: set[str] = set()
        for dep_name in deps:
            comp = _COMPONENT_ENV.get(dep_name)
            if isinstance(comp, _Comp) and comp.io_refs:
                io_deps.update(comp.io_refs)
        io_deps_sx = (
            "(" + " ".join(_sx_literal(n) for n in sorted(io_deps)) + ")"
            if io_deps else "()"
        )

        # Extract layout identity for client-side routing.
        # When layout changes between pages, client routing falls through
        # to server so OOB header updates are applied.
        layout_id = ""
        if isinstance(page_def.layout, str):
            layout_id = page_def.layout
        elif isinstance(page_def.layout, list):
            from .types import Keyword as _Kw, Symbol as _Sym
            first = page_def.layout[0]
            if isinstance(first, _Kw):
                layout_id = first.name
            elif isinstance(first, _Sym):
                layout_id = first.name
            else:
                layout_id = str(first)
            # Append section kwarg to distinguish same-layout-type
            # with different sections (e.g. sx-section+Docs vs sx-section+Testing)
            raw_layout = page_def.layout
            for li in range(1, len(raw_layout) - 1):
                if isinstance(raw_layout[li], _Kw) and raw_layout[li].name == "section":
                    val = raw_layout[li + 1]
                    if val is not None:
                        layout_id = f"{layout_id}:{val}"
                    break

        # Build closure as SX dict
        closure_parts: list[str] = []
        for k, v in page_def.closure.items():
            if isinstance(v, (str, int, float, bool)):
                closure_parts.append(f":{k} {_sx_literal(v)}")
        closure_sx = "{" + " ".join(closure_parts) + "}"

        stream = "true" if page_def.stream else "false"

        # Render plan: which components render where
        plan = page_def.render_plan
        if plan:
            server_sx = "(" + " ".join(_sx_literal(n) for n in plan.get("server", [])) + ")"
            client_sx = "(" + " ".join(_sx_literal(n) for n in plan.get("client", [])) + ")"
            render_plan_sx = "{:server " + server_sx + " :client " + client_sx + "}"
        else:
            render_plan_sx = "{:server () :client ()}"

        entry = (
            "{:name " + _sx_literal(page_def.name)
            + " :path " + _sx_literal(page_def.path)
            + " :auth " + _sx_literal(auth)
            + " :has-data " + has_data
            + " :stream " + stream
            + " :layout " + _sx_literal(layout_id)
            + " :io-deps " + io_deps_sx
            + " :render-plan " + render_plan_sx
            + " :content " + _sx_literal(content_src)
            + " :deps " + deps_sx
            + " :closure " + closure_sx + "}"
        )
        entries.append(entry)

    result = "\n".join(entries)
    _log.debug("_build_pages_sx(%s): built %d entries, %d bytes", service, len(entries), len(result))
    return result


def _sx_literal(v: object) -> str:
    """Serialize a Python value as an SX literal."""
    if v is None:
        return "nil"
    if isinstance(v, bool):
        return "true" if v else "false"
    if isinstance(v, (int, float)):
        return str(v)
    if isinstance(v, str):
        escaped = v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
        return f'"{escaped}"'
    return "nil"



_cached_shell_static: dict[str, Any] | None = None
_cached_shell_comp_hash: str | None = None


def invalidate_shell_cache():
    """Call on component hot-reload to recompute shell statics."""
    global _cached_shell_static, _cached_shell_comp_hash
    _cached_shell_static = None
    _cached_shell_comp_hash = None


def _get_shell_static() -> dict[str, Any]:
    """Compute and cache all shell kwargs that don't change per-request.

    This is the expensive part: component dep scanning, serialization,
    CSS class scanning, rule lookup, pages registry. All stable until
    components are hot-reloaded.
    """
    global _cached_shell_static, _cached_shell_comp_hash
    from .jinja_bridge import components_for_page, css_classes_for_page, _component_env_hash
    from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash

    current_hash = _component_env_hash()
    if _cached_shell_static is not None and _cached_shell_comp_hash == current_hash:
        return _cached_shell_static

    import time
    t0 = time.monotonic()

    from quart import current_app as _ca
    from .jinja_bridge import client_components_tag, _COMPONENT_ENV, _CLIENT_LIBRARY_SOURCES
    from .jinja_bridge import _component_env_hash
    from .parser import serialize as _serialize

    # Send ALL component definitions — the hash is stable per env so the
    # browser caches them across all pages. Server-side expansion handles
    # the per-page subset; the client needs the full set for client-side
    # routing to any page.
    parts = []
    for key, val in _COMPONENT_ENV.items():
        from .types import Island, Component, Macro
        if isinstance(val, Island):
            ps = ["&key"] + list(val.params)
            if val.has_children: ps.extend(["&rest", "children"])
            parts.append(f"(defisland ~{val.name} ({' '.join(ps)}) {_serialize(val.body, pretty=True)})")
        elif isinstance(val, Component):
            ps = ["&key"] + list(val.params)
            if val.has_children: ps.extend(["&rest", "children"])
            parts.append(f"(defcomp ~{val.name} ({' '.join(ps)}) {_serialize(val.body, pretty=True)})")
        elif isinstance(val, Macro):
            ps = list(val.params)
            if val.rest_param: ps.extend(["&rest", val.rest_param])
            parts.append(f"(defmacro {val.name} ({' '.join(ps)}) {_serialize(val.body, pretty=True)})")
    all_parts = list(_CLIENT_LIBRARY_SOURCES) + parts
    component_defs = "\n".join(all_parts)
    component_hash = _component_env_hash()

    # CSS: scan ALL components (not per-page) for the static cache
    sx_css = ""
    sx_css_classes = ""
    if registry_loaded():
        classes: set[str] = set()
        from .types import Island as _I, Component as _C
        for val in _COMPONENT_ENV.values():
            if isinstance(val, (_I, _C)) and val.css_classes:
                classes.update(val.css_classes)
        classes.update(["bg-stone-50", "text-stone-900"])
        rules = lookup_rules(classes)
        sx_css = get_preamble() + rules
        sx_css_classes = store_css_hash(classes)

    pages_sx = _build_pages_sx(_ca.name)

    _shell_cfg = _ca.config.get("SX_SHELL", {})

    static = dict(
        component_hash=component_hash,
        component_defs=component_defs,
        pages_sx=pages_sx,
        sx_css=sx_css,
        sx_css_classes=sx_css_classes,
        wasm_hash=_wasm_hash("sx_browser.bc.wasm.js"),
        platform_hash=_wasm_hash("sx-platform.js"),
        sxbc_hash=_sxbc_hash(),
        asset_url=_ca.config.get("ASSET_URL", "/static"),
        inline_css=_shell_cfg.get("inline_css"),
        inline_head_js=_shell_cfg.get("inline_head_js"),
        init_sx=_shell_cfg.get("init_sx"),
    )

    t1 = time.monotonic()
    import logging
    logging.getLogger("sx.pages").info(
        "[shell-static] computed in %.3fs, comp_defs=%d css=%d pages=%d",
        t1 - t0, len(component_defs), len(sx_css), len(pages_sx))

    _cached_shell_static = static
    _cached_shell_comp_hash = current_hash
    return static


async def _build_shell_kwargs(ctx: dict, page_sx: str, *,
                              meta_html: str = "",
                              head_scripts: list[str] | None = None,
                              inline_css: str | None = None,
                              inline_head_js: str | None = None,
                              init_sx: str | None = None,
                              body_scripts: list[str] | None = None) -> dict[str, Any]:
    """Compute all shell kwargs for sx-page-shell.

    Static parts (components, CSS, pages) are cached. Only per-request
    values (title, csrf) are computed fresh.
    """
    static = _get_shell_static()

    asset_url = get_asset_url(ctx) or static["asset_url"]
    title = ctx.get("base_title", "Rose Ash")
    csrf = _get_csrf_token()

    kwargs: dict[str, Any] = dict(static)
    kwargs.update(
        title=_html_escape(title),
        asset_url=asset_url,
        meta_html=meta_html,
        csrf=_html_escape(csrf),
    )

    # Per-page CSS: scan THIS page's classes and add to cached CSS
    from .css_registry import scan_classes_from_sx, lookup_rules, registry_loaded
    if registry_loaded() and page_sx:
        page_classes = scan_classes_from_sx(page_sx)
        if page_classes:
            extra_rules = lookup_rules(page_classes)
            if extra_rules:
                kwargs["sx_css"] = static["sx_css"] + extra_rules

    # Cookie-based component caching
    client_hash = _get_sx_comp_cookie()
    if not _is_dev_mode() and client_hash and client_hash == static["component_hash"]:
        kwargs["component_defs"] = ""

    # Per-call overrides
    if head_scripts is not None:
        kwargs["head_scripts"] = head_scripts
    if inline_css is not None:
        kwargs["inline_css"] = inline_css
    if inline_head_js is not None:
        kwargs["inline_head_js"] = inline_head_js
    if init_sx is not None:
        kwargs["init_sx"] = init_sx
    if body_scripts is not None:
        kwargs["body_scripts"] = body_scripts
    return kwargs


async def sx_page(ctx: dict, page_sx: str, *,
              meta_html: str = "",
              head_scripts: list[str] | None = None,
              inline_css: str | None = None,
              inline_head_js: str | None = None,
              init_sx: str | None = None,
              body_scripts: list[str] | None = None) -> str:
    """Return a minimal HTML shell that boots the page from sx source."""
    # Ensure page_sx is a plain str
    if isinstance(page_sx, SxExpr):
        page_sx = "".join([page_sx])

    kwargs = await _build_shell_kwargs(
        ctx, page_sx, meta_html=meta_html,
        head_scripts=head_scripts, inline_css=inline_css,
        inline_head_js=inline_head_js, init_sx=init_sx,
        body_scripts=body_scripts)
    kwargs["page_sx"] = page_sx
    return await render_to_html("shared:shell/sx-page-shell", **kwargs)


_SX_STREAMING_RESOLVE = """\
<script>window.__sxResolve&&window.__sxResolve({id},{sx})</script>"""

_SX_STREAMING_BOOTSTRAP = """\
<script>window.__sxPending=[];window.__sxResolve=function(i,s){\
if(window.Sx&&Sx.resolveSuspense){Sx.resolveSuspense(i,s)}\
else{window.__sxPending.push({id:i,sx:s})}}</script>"""


def sx_page_streaming_parts(ctx: dict, page_html: str, *,
                             page_sx: str = "",
                             meta_html: str = "") -> tuple[str, str]:
    """Split the page into shell (before scripts) and tail (scripts).

    For streaming, the initial page is rendered to **HTML** server-side so
    ``[data-suspense]`` elements are in the DOM immediately — no client-side
    SX rendering needed for the shell. Resolution scripts can find and
    replace suspense placeholders without waiting for sx-browser.js to boot.

    Args:
        page_html: Server-rendered HTML for the page body (with suspense
                   placeholders already as real HTML elements).
        page_sx:   SX source scanned for component deps (may differ from
                   page_html when components were expanded server-side).
    """
    from .jinja_bridge import components_for_page, css_classes_for_page
    from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash

    from quart import current_app as _ca

    # Scan the SX source for component deps (needed for resolution scripts
    # that may contain component calls the client must render)
    scan_source = page_sx or page_html
    component_defs, component_hash = components_for_page(scan_source, service=_ca.name)

    client_hash = _get_sx_comp_cookie()
    if not _is_dev_mode() and client_hash and client_hash == component_hash:
        component_defs = ""

    sx_css = ""
    sx_css_classes = ""
    if registry_loaded():
        classes = css_classes_for_page(scan_source, service=_ca.name)
        classes.update(["bg-stone-50", "text-stone-900"])
        rules = lookup_rules(classes)
        sx_css = get_preamble() + rules
        sx_css_classes = store_css_hash(classes)

    asset_url = get_asset_url(ctx)
    title = ctx.get("base_title", "Rose Ash")
    csrf = _get_csrf_token()

    import logging
    from quart import current_app
    pages_sx = _build_pages_sx(current_app.name)

    wasm_hash = _wasm_hash("sx_browser.bc.wasm.js")
    platform_hash = _wasm_hash("sx-platform.js")
    sxbc_hash = _sxbc_hash()

    # Shell: head + body with server-rendered HTML (not SX mount script)
    shell = (
        '<!doctype html>\n<html lang="en">\n<head>\n'
        '<meta charset="utf-8">\n'
        '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
        '<meta name="robots" content="index,follow">\n'
        '<meta name="theme-color" content="#ffffff">\n'
        f'<title>{_html_escape(title)}</title>\n'
        f'{meta_html}'
        '<style>@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }</style>\n'
        f'<meta name="csrf-token" content="{_html_escape(csrf)}">\n'
        f'<style id="sx-css">{sx_css}</style>\n'
        f'<meta name="sx-css-classes" content="{sx_css_classes}">\n'
        "<script>if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}</script>\n"
        "<script>document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})</script>\n"
        '<style>\n'
        'details[data-toggle-group="mobile-panels"]>summary{list-style:none}\n'
        'details[data-toggle-group="mobile-panels"]>summary::-webkit-details-marker{display:none}\n'
        '@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}\n'
        'img{max-width:100%;height:auto}\n'
        '.clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}\n'
        '.clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}\n'
        '.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}\n'
        'details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}\n'
        '.sx-indicator{display:none}.sx-request .sx-indicator{display:inline-flex}\n'
        '.sx-error .sx-indicator{display:none}.sx-loading .sx-indicator{display:inline-flex}\n'
        '.js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}\n'
        '</style>\n'
        '</head>\n'
        '<body class="bg-stone-50 text-stone-900">\n'
        f'<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>\n'
        f'<script type="text/sx-pages">{pages_sx}</script>\n'
        # Server-rendered HTML — suspense placeholders are real DOM elements
        f'{page_html}\n'
    )

    # Tail: bootstrap suspense resolver + scripts + close
    tail = (
        _SX_STREAMING_BOOTSTRAP + '\n'
        + f'<script src="{asset_url}/wasm/sx_browser.bc.wasm.js?v={wasm_hash}"></script>\n'
          f'<script src="{asset_url}/wasm/sx-platform.js?v={platform_hash}" data-sxbc-hash="{sxbc_hash}"></script>\n'
    )

    return shell, tail


def sx_streaming_resolve_script(suspension_id: str, sx_source: str,
                                extra_components: str = "") -> str:
    """Build a <script> tag that resolves a streaming suspense placeholder.

    If *extra_components* is non-empty, a ``<script type="text/sx">`` block
    is prepended so the client loads those component defs before resolving.
    """
    import json
    parts = []
    if extra_components:
        parts.append(f'<script type="text/sx">{extra_components}</script>')
    parts.append(_SX_STREAMING_RESOLVE.format(
        id=json.dumps(suspension_id),
        sx=json.dumps(sx_source),
    ))
    return "\n".join(parts)


_SCRIPT_HASH_CACHE: dict[str, str] = {}


def _script_hash(filename: str) -> str:
    """Compute MD5 hash of a static script file, cached for process lifetime."""
    if filename not in _SCRIPT_HASH_CACHE:
        try:
            data = (Path(__file__).resolve().parent.parent / "static" / "scripts" / filename).read_bytes()
            _SCRIPT_HASH_CACHE[filename] = hashlib.md5(data).hexdigest()[:8]
        except OSError:
            _SCRIPT_HASH_CACHE[filename] = "dev"
    return _SCRIPT_HASH_CACHE[filename]


def _wasm_hash(filename: str) -> str:
    """Compute MD5 hash of a static wasm file, cached for process lifetime."""
    key = "wasm/" + filename
    if key not in _SCRIPT_HASH_CACHE:
        try:
            data = (Path(__file__).resolve().parent.parent / "static" / "wasm" / filename).read_bytes()
            _SCRIPT_HASH_CACHE[key] = hashlib.md5(data).hexdigest()[:8]
        except OSError:
            _SCRIPT_HASH_CACHE[key] = "dev"
    return _SCRIPT_HASH_CACHE[key]


def _sxbc_hash() -> str:
    """Compute combined MD5 hash of all .sxbc files, cached for process lifetime."""
    key = "sxbc/_combined"
    if key not in _SCRIPT_HASH_CACHE:
        try:
            sxbc_dir = Path(__file__).resolve().parent.parent / "static" / "wasm" / "sx"
            h = hashlib.md5()
            for p in sorted(sxbc_dir.glob("*.sxbc")):
                h.update(p.read_bytes())
            _SCRIPT_HASH_CACHE[key] = h.hexdigest()[:8]
        except OSError:
            _SCRIPT_HASH_CACHE[key] = "dev"
    return _SCRIPT_HASH_CACHE[key]


def _get_csrf_token() -> str:
    """Get the CSRF token from the current request context."""
    try:
        from quart import g
        return getattr(g, "csrf_token", "")
    except RuntimeError:
        return ""


def _get_sx_comp_cookie() -> str:
    """Read the sx-comp-hash cookie from the current request."""
    try:
        from quart import request
        return request.cookies.get("sx-comp-hash", "")
    except RuntimeError:
        return ""


def _is_dev_mode() -> bool:
    """Check if running in dev mode (RELOAD=true)."""
    import os
    return os.getenv("RELOAD") == "true"


def _pretty_print_sx_body(body: str) -> str:
    """Pretty-print the sx portion of a response body, preserving HTML blocks."""
    import re
    from .parser import parse_all as _parse_all, serialize as _serialize

    # Split HTML prefix blocks (<style>, <script>) from the sx tail
    # These are always at the start, each on its own line
    parts: list[str] = []
    rest = body
    while rest.startswith("<"):
        end = rest.find(">", rest.find("</")) + 1
        if end <= 0:
            break
        # Find end of the closing tag
        tag_match = re.match(r'<(style|script)[^>]*>[\s\S]*?</\1>', rest)
        if tag_match:
            parts.append(tag_match.group(0))
            rest = rest[tag_match.end():].lstrip("\n")
        else:
            break

    sx_source = rest.strip()
    if not sx_source or sx_source[0] != "(":
        return body

    try:
        exprs = _parse_all(sx_source)
        if len(exprs) == 1:
            parts.append(_serialize(exprs[0], pretty=True))
        else:
            # Multiple top-level expressions — indent each
            pretty_parts = [_serialize(expr, pretty=True) for expr in exprs]
            parts.append("\n\n".join(pretty_parts))
        return "\n\n".join(parts)
    except Exception as e:
        import logging
        logging.getLogger("sx").warning("Pretty-print sx body failed: %s", e)
        return body


def _html_escape(s: str) -> str:
    """Minimal HTML escaping for attribute values."""
    return (s.replace("&", "&amp;")
             .replace("<", "&lt;")
             .replace(">", "&gt;")
             .replace('"', "&quot;"))
