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

from typing import Any

from shared.sx.parser import serialize, SxExpr
from shared.sx.helpers import (
    render_to_sx,
    post_header_sx as _post_header_sx,
    post_admin_header_sx,
    oob_header_sx as _oob_header_sx,
    header_child_sx,
    search_mobile_sx, search_desktop_sx,
    full_page_sx, oob_page_sx,
)


def setup_market_pages() -> None:
    """Register market-specific layouts, page helpers, and load page definitions."""
    _register_market_layouts()
    _register_market_helpers()
    _load_market_page_files()


def _load_market_page_files() -> None:
    import os
    from shared.sx.pages import load_page_dir
    from shared.sx.jinja_bridge import load_service_components
    base = os.path.dirname(os.path.dirname(__file__))
    load_service_components(base, service_name="market")
    load_page_dir(os.path.dirname(__file__), "market")


# ---------------------------------------------------------------------------
# OOB orphan cleanup
# ---------------------------------------------------------------------------

_MARKET_DEEP_IDS = [
    "product-admin-row", "product-admin-header-child",
    "product-row", "product-header-child",
    "market-admin-row", "market-admin-header-child",
    "market-row", "market-header-child",
    "post-admin-row", "post-admin-header-child",
]


def _clear_deeper_oob(*keep_ids: str) -> str:
    """Clear all market header rows/children NOT in keep_ids."""
    to_clear = [i for i in _MARKET_DEEP_IDS if i not in keep_ids]
    return " ".join(f'(div :id "{i}" :sx-swap-oob "outerHTML")' for i in to_clear)


# ---------------------------------------------------------------------------
# Price helpers
# ---------------------------------------------------------------------------

_SYM = {"GBP": "\u00a3", "EUR": "\u20ac", "USD": "$"}


def _price_str(val, raw, cur) -> str:
    if raw:
        return str(raw)
    if isinstance(val, (int, float)):
        return f"{_SYM.get(cur, '')}{val:.2f}"
    return str(val or "")


def _set_prices(item: dict) -> dict:
    """Extract price values from product dict (mirrors prices.html set_prices macro)."""
    oe = item.get("oe_list_price") or {}
    sp_val = item.get("special_price") or (oe.get("special") if oe else None)
    sp_raw = item.get("special_price_raw") or (oe.get("special_raw") if oe else None)
    sp_cur = item.get("special_price_currency") or (oe.get("special_currency") if oe else None)
    rp_val = item.get("regular_price") or item.get("rrp") or (oe.get("rrp") if oe else None)
    rp_raw = item.get("regular_price_raw") or item.get("rrp_raw") or (oe.get("rrp_raw") if oe else None)
    rp_cur = item.get("regular_price_currency") or item.get("rrp_currency") or (oe.get("rrp_currency") if oe else None)
    return dict(sp_val=sp_val, sp_raw=sp_raw, sp_cur=sp_cur,
                rp_val=rp_val, rp_raw=rp_raw, rp_cur=rp_cur)


async def _card_price_sx(p: dict) -> str:
    """Build price line for product card as sx call."""
    pr = _set_prices(p)
    sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"])
    rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"])
    parts: list[str] = []
    if pr["sp_val"]:
        parts.append(await render_to_sx("market-price-special", price=sp_str))
        if pr["rp_val"]:
            parts.append(await render_to_sx("market-price-regular-strike", price=rp_str))
    elif pr["rp_val"]:
        parts.append(await render_to_sx("market-price-regular", price=rp_str))
    inner = "(<> " + " ".join(parts) + ")" if parts else None
    return await render_to_sx("market-price-line", inner=SxExpr(inner) if inner else None)


# ---------------------------------------------------------------------------
# Header helpers -- _post_header_sx and _oob_header_sx imported from shared
# ---------------------------------------------------------------------------


async def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
    """Build the market-level header row as sx call string."""
    from quart import url_for

    market_title = ctx.get("market_title", "")
    top_slug = ctx.get("top_slug", "")
    sub_slug = ctx.get("sub_slug", "")
    hx_select_search = ctx.get("hx_select_search", "#main-panel")

    sub_div = f'(div {serialize(sub_slug)})' if sub_slug else ""
    label_sx = await render_to_sx(
        "market-shop-label",
        title=market_title, top_slug=top_slug or "",
        sub_div=SxExpr(sub_div) if sub_div else None,
    )

    link_href = url_for("defpage_market_home")

    # Build desktop nav from categories
    categories = ctx.get("categories", {})
    qs = ctx.get("qs", "")
    nav_sx = await _desktop_category_nav_sx(ctx, categories, qs, hx_select_search)

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


async def _desktop_category_nav_sx(ctx: dict, categories: dict, qs: str,
                                hx_select: str) -> str:
    """Build desktop category navigation links as sx."""
    from quart import url_for
    from shared.utils import route_prefix

    prefix = route_prefix()
    category_label = ctx.get("category_label", "")
    select_colours = ctx.get("select_colours", "")
    rights = ctx.get("rights", {})

    all_href = prefix + url_for("market.browse.browse_all") + qs
    all_active = (category_label == "All Products")
    link_parts = [await render_to_sx(
        "market-category-link",
        href=all_href, hx_select=hx_select, active=all_active,
        select_colours=select_colours, label="All",
    )]

    for cat, data in categories.items():
        cat_href = prefix + url_for("market.browse.browse_top", top_slug=data["slug"]) + qs
        cat_active = (cat == category_label)
        link_parts.append(await render_to_sx(
            "market-category-link",
            href=cat_href, hx_select=hx_select, active=cat_active,
            select_colours=select_colours, label=cat,
        ))

    links_sx = "(<> " + " ".join(link_parts) + ")"

    admin_sx = ""
    if rights and rights.get("admin"):
        admin_href = prefix + url_for("market.admin.defpage_market_admin")
        admin_sx = await render_to_sx("market-admin-link", href=admin_href, hx_select=hx_select)

    return await render_to_sx("market-desktop-category-nav",
                     links=SxExpr(links_sx),
                     admin=SxExpr(admin_sx) if admin_sx else None)


async def _product_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
    """Build the product-level header row as sx call string."""
    from quart import url_for
    from shared.browser.app.csrf import generate_csrf_token

    slug = d.get("slug", "")
    title = d.get("title", "")
    hx_select_search = ctx.get("hx_select_search", "#main-panel")
    link_href = url_for("market.browse.product.product_detail", product_slug=slug)

    label_sx = await render_to_sx("market-product-label", title=title)

    # Prices in nav area
    pr = _set_prices(d)
    cart = ctx.get("cart", [])
    prices_nav = await _prices_header_sx(d, pr, cart, slug, ctx)

    rights = ctx.get("rights", {})
    nav_parts = [prices_nav]
    if rights and rights.get("admin"):
        admin_href = url_for("market.browse.product.admin", product_slug=slug)
        nav_parts.append(await render_to_sx("market-admin-link", href=admin_href, hx_select=hx_select_search))
    nav_sx = "(<> " + " ".join(nav_parts) + ")"

    return await render_to_sx(
        "menu-row-sx",
        id="product-row", level=3,
        link_href=link_href, link_label_content=SxExpr(label_sx),
        nav=SxExpr(nav_sx), child_id="product-header-child", oob=oob,
    )


async def _prices_header_sx(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str:
    """Build prices + add-to-cart for product header row as sx."""
    from quart import url_for
    from shared.browser.app.csrf import generate_csrf_token

    csrf = generate_csrf_token()
    cart_action = url_for("market.browse.product.cart", product_slug=slug)
    cart_url_fn = ctx.get("cart_url")

    # Add-to-cart button
    quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
    add_sx = await _cart_add_sx(slug, quantity, cart_action, csrf, cart_url_fn)

    parts = [add_sx]
    sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val")
    if sp_val:
        parts.append(await render_to_sx("market-header-price-special-label"))
        parts.append(await render_to_sx("market-header-price-special",
                               price=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"])))
        if rp_val:
            parts.append(await render_to_sx("market-header-price-strike",
                                   price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])))
    elif rp_val:
        parts.append(await render_to_sx("market-header-price-regular-label"))
        parts.append(await render_to_sx("market-header-price-regular",
                               price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])))

    # RRP
    rrp_raw = d.get("rrp_raw")
    rrp_val = d.get("rrp")
    case_size = d.get("case_size_count") or 1
    if rrp_raw and rrp_val:
        rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}"
        parts.append(await render_to_sx("market-header-rrp", rrp=rrp_str))

    inner_sx = "(<> " + " ".join(parts) + ")"
    return await render_to_sx("market-prices-row", inner=SxExpr(inner_sx))


async def _cart_add_sx(slug: str, quantity: int, action: str, csrf: str,
                   cart_url_fn: Any = None) -> str:
    """Build add-to-cart button or quantity controls as sx."""
    if not quantity:
        return await render_to_sx(
            "market-cart-add-empty",
            cart_id=f"cart-{slug}", action=action, csrf=csrf,
        )

    cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/"
    return await render_to_sx(
        "market-cart-add-quantity",
        cart_id=f"cart-{slug}", action=action, csrf=csrf,
        minus_val=str(quantity - 1), plus_val=str(quantity + 1),
        quantity=str(quantity), cart_href=cart_href,
    )


# ---------------------------------------------------------------------------
# Mobile nav panel
# ---------------------------------------------------------------------------

async def _mobile_nav_panel_sx(ctx: dict) -> str:
    """Build mobile nav panel with category accordion as sx."""
    from quart import url_for
    from shared.utils import route_prefix

    prefix = route_prefix()
    categories = ctx.get("categories", {})
    qs = ctx.get("qs", "")
    category_label = ctx.get("category_label", "")
    top_slug = ctx.get("top_slug", "")
    sub_slug = ctx.get("sub_slug", "")
    hx_select = ctx.get("hx_select_search", "#main-panel")
    select_colours = ctx.get("select_colours", "")

    all_href = prefix + url_for("market.browse.browse_all") + qs
    all_active = (category_label == "All Products")
    item_parts = [await render_to_sx(
        "market-mobile-all-link",
        href=all_href, hx_select=hx_select, active=all_active,
        select_colours=select_colours,
    )]

    for cat, data in categories.items():
        cat_slug = data.get("slug", "")
        cat_active = (top_slug == cat_slug.lower() if top_slug else False)
        cat_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
        bg_cls = " bg-stone-900 text-white hover:bg-stone-900" if cat_active else ""

        chevron_sx = await render_to_sx("market-mobile-chevron")

        cat_count = data.get("count", 0)
        summary_sx = await render_to_sx(
            "market-mobile-cat-summary",
            bg_cls=bg_cls, href=cat_href, hx_select=hx_select,
            select_colours=select_colours, cat_name=cat,
            count_label=f"{cat_count} products", count_str=str(cat_count),
            chevron=SxExpr(chevron_sx),
        )

        subs = data.get("subs", [])
        subs_sx = ""
        if subs:
            sub_link_parts = []
            for sub in subs:
                sub_href = prefix + url_for("market.browse.browse_sub", top_slug=cat_slug, sub_slug=sub["slug"]) + qs
                sub_active = (cat_active and sub_slug == sub.get("slug"))
                sub_label = sub.get("html_label") or sub.get("name", "")
                sub_count = sub.get("count", 0)
                sub_link_parts.append(await render_to_sx(
                    "market-mobile-sub-link",
                    select_colours=select_colours, active=sub_active,
                    href=sub_href, hx_select=hx_select, label=sub_label,
                    count_label=f"{sub_count} products", count_str=str(sub_count),
                ))
            sub_links_sx = "(<> " + " ".join(sub_link_parts) + ")"
            subs_sx = await render_to_sx("market-mobile-subs-panel", links=SxExpr(sub_links_sx))
        else:
            view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
            subs_sx = await render_to_sx("market-mobile-view-all", href=view_href, hx_select=hx_select)

        item_parts.append(await render_to_sx(
            "market-mobile-cat-details",
            open=cat_active or None,
            summary=SxExpr(summary_sx),
            subs=SxExpr(subs_sx),
        ))

    items_sx = "(<> " + " ".join(item_parts) + ")"
    return await render_to_sx("market-mobile-nav-wrapper", items=SxExpr(items_sx))


# ---------------------------------------------------------------------------
# Product card (browse grid item)
# ---------------------------------------------------------------------------

async def _product_card_sx(p: dict, ctx: dict) -> str:
    """Build a single product card for browse grid as sx call."""
    from quart import url_for
    from shared.browser.app.csrf import generate_csrf_token
    from shared.utils import route_prefix

    prefix = route_prefix()
    slug = p.get("slug", "")
    item_href = prefix + url_for("market.browse.product.product_detail", product_slug=slug)
    hx_select = ctx.get("hx_select_search", "#main-panel")
    asset_url_fn = ctx.get("asset_url")
    cart = ctx.get("cart", [])
    selected_brands = ctx.get("selected_brands", [])
    selected_stickers = ctx.get("selected_stickers", [])
    search = ctx.get("search", "")
    user = ctx.get("user")
    csrf = generate_csrf_token()

    # Price data
    pr = _set_prices(p)
    sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"])
    rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"])

    # Image labels as src URLs
    labels = p.get("labels", [])
    label_srcs = []
    if p.get("image") and callable(asset_url_fn):
        label_srcs = [asset_url_fn("labels/" + l + ".svg") for l in labels]

    # Stickers as data
    raw_stickers = p.get("stickers", [])
    sticker_data = []
    if raw_stickers and callable(asset_url_fn):
        for s in raw_stickers:
            ring = " ring-2 ring-emerald-500 rounded" if s in selected_stickers else ""
            sticker_data.append({"src": asset_url_fn(f"stickers/{s}.svg"), "name": s, "ring-cls": ring})

    # Title highlighting
    title = p.get("title", "")
    has_highlight = False
    search_pre = search_mid = search_post = ""
    if search and search.lower() in title.lower():
        idx = title.lower().index(search.lower())
        has_highlight = True
        search_pre = title[:idx]
        search_mid = title[idx:idx + len(search)]
        search_post = title[idx + len(search):]

    # Cart
    quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
    cart_action = url_for("market.browse.product.cart", product_slug=slug)
    cart_url_fn = ctx.get("cart_url")
    cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/"

    brand = p.get("brand", "")
    brand_highlight = " bg-yellow-200" if brand in selected_brands else ""

    kwargs = dict(
        href=item_href, hx_select=hx_select, slug=slug,
        image=p.get("image", ""), brand=brand, brand_highlight=brand_highlight,
        special_price=sp_str, regular_price=rp_str,
        cart_action=cart_action, quantity=quantity, cart_href=cart_href, csrf=csrf,
        title=title,
        has_like=bool(user),
    )

    if label_srcs:
        kwargs["labels"] = label_srcs
    elif labels:
        kwargs["labels"] = labels

    if user:
        kwargs["liked"] = p.get("is_liked", False)
        kwargs["like_action"] = url_for("market.browse.product.like_toggle", product_slug=slug)

    if sticker_data:
        kwargs["stickers"] = sticker_data

    if has_highlight:
        kwargs["has_highlight"] = True
        kwargs["search_pre"] = search_pre
        kwargs["search_mid"] = search_mid
        kwargs["search_post"] = search_post

    return await render_to_sx("market-product-card", **kwargs)


async def _product_cards_sx(ctx: dict) -> str:
    """S-expression wire format for product cards (client renders)."""
    from shared.utils import route_prefix

    prefix = route_prefix()
    products = ctx.get("products", [])
    page = ctx.get("page", 1)
    total_pages = ctx.get("total_pages", 1)
    current_local_href = ctx.get("current_local_href", "/")
    qs_fn = ctx.get("qs_filter")

    parts = []
    for p in products:
        parts.append(await _product_card_sx(p, ctx))

    if page < total_pages:
        if callable(qs_fn):
            next_qs = qs_fn({"page": page + 1})
        else:
            next_qs = f"?page={page + 1}"
        next_url = prefix + current_local_href + next_qs
        parts.append(await render_to_sx("sentinel-mobile",
                               id=f"sentinel-{page}-m", next_url=next_url,
                               hyperscript=_MOBILE_SENTINEL_HS))
        parts.append(await render_to_sx("sentinel-desktop",
                               id=f"sentinel-{page}-d", next_url=next_url,
                               hyperscript=_DESKTOP_SENTINEL_HS))
    else:
        parts.append(await render_to_sx("end-of-results"))

    return "(<> " + " ".join(parts) + ")"


async def _like_button_sx(slug: str, liked: bool, csrf: str, ctx: dict) -> str:
    """Build the like/unlike heart button overlay as sx."""
    from quart import url_for

    action = url_for("market.browse.product.like_toggle", product_slug=slug)
    icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400"
    return await render_to_sx(
        "market-like-button",
        form_id=f"like-{slug}", action=action, slug=slug,
        csrf=csrf, icon_cls=icon_cls,
    )


# ---------------------------------------------------------------------------
# Product cards (pagination fragment)
# ---------------------------------------------------------------------------

_MOBILE_SENTINEL_HS = (
    "init\n"
    "  if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end\n"
    "  if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end\n"
    "on resize from window\n"
    "  if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end\n"
    "on htmx:beforeRequest\n"
    "  if window.matchMedia('(min-width: 768px)').matches then halt end\n"
    "  add .hidden to .js-neterr in me\n"
    "  remove .hidden from .js-loading in me\n"
    "  remove .opacity-100 from me\n"
    "  add .opacity-0 to me\n"
    "def backoff()\n"
    "  set ms to me.dataset.retryMs\n"
    "  if ms > 30000 then set ms to 30000 end\n"
    "  add .hidden to .js-loading in me\n"
    "  remove .hidden from .js-neterr in me\n"
    "  remove .opacity-0 from me\n"
    "  add .opacity-100 to me\n"
    "  wait ms ms\n"
    "  trigger sentinelmobile:retry\n"
    "  set ms to ms * 2\n"
    "  if ms > 30000 then set ms to 30000 end\n"
    "  set me.dataset.retryMs to ms\n"
    "end\n"
    "on htmx:sendError call backoff()\n"
    "on htmx:responseError call backoff()\n"
    "on htmx:timeout call backoff()"
)

_DESKTOP_SENTINEL_HS = (
    "init\n"
    "  if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end\n"
    "on htmx:beforeRequest(event)\n"
    "  add .hidden to .js-neterr in me\n"
    "  remove .hidden from .js-loading in me\n"
    "  remove .opacity-100 from me\n"
    "  add .opacity-0 to me\n"
    "  set trig to null\n"
    "  if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end\n"
    "  if trig and trig.type is 'intersect'\n"
    "    set scroller to the closest .js-grid-viewport\n"
    "    if scroller is null then halt end\n"
    "    if scroller.scrollTop < 20 then halt end\n"
    "  end\n"
    "def backoff()\n"
    "  set ms to me.dataset.retryMs\n"
    "  if ms > 30000 then set ms to 30000 end\n"
    "  add .hidden to .js-loading in me\n"
    "  remove .hidden from .js-neterr in me\n"
    "  remove .opacity-0 from me\n"
    "  add .opacity-100 to me\n"
    "  wait ms ms\n"
    "  trigger sentinel:retry\n"
    "  set ms to ms * 2\n"
    "  if ms > 30000 then set ms to 30000 end\n"
    "  set me.dataset.retryMs to ms\n"
    "end\n"
    "on htmx:sendError call backoff()\n"
    "on htmx:responseError call backoff()\n"
    "on htmx:timeout call backoff()"
)


# ---------------------------------------------------------------------------
# Browse filter panels (mobile + desktop)
# ---------------------------------------------------------------------------

async def _desktop_filter_sx(ctx: dict) -> str:
    """Build the desktop aside filter panel as sx."""
    category_label = ctx.get("category_label", "")
    sort_options = ctx.get("sort_options", [])
    sort = ctx.get("sort", "")
    labels = ctx.get("labels", [])
    selected_labels = ctx.get("selected_labels", [])
    stickers = ctx.get("stickers", [])
    selected_stickers = ctx.get("selected_stickers", [])
    brands = ctx.get("brands", [])
    selected_brands = ctx.get("selected_brands", [])
    liked = ctx.get("liked", False)
    liked_count = ctx.get("liked_count", 0)
    subs_local = ctx.get("subs_local", [])
    top_local_href = ctx.get("top_local_href", "")
    sub_slug = ctx.get("sub_slug", "")

    # Search
    search_sx = await search_desktop_sx(ctx)

    # Category summary + sort + like + labels + stickers
    cat_parts = [await render_to_sx("market-filter-category-label", label=category_label)]

    if sort_options:
        cat_parts.append(await _sort_stickers_sx(sort_options, sort, ctx))

    like_label_parts = [await _like_filter_sx(liked, liked_count, ctx)]
    if labels:
        like_label_parts.append(await _labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels"))
    like_labels_sx = "(<> " + " ".join(like_label_parts) + ")"
    cat_parts.append(await render_to_sx("market-filter-like-labels-nav", inner=SxExpr(like_labels_sx)))

    if stickers:
        cat_parts.append(await _stickers_filter_sx(stickers, selected_stickers, ctx))

    if subs_local and top_local_href:
        cat_parts.append(await _subcategory_selector_sx(subs_local, top_local_href, sub_slug, ctx))

    cat_inner_sx = "(<> " + " ".join(cat_parts) + ")"
    cat_summary = await render_to_sx("market-desktop-category-summary", inner=SxExpr(cat_inner_sx))

    # Brand filter
    brand_inner = ""
    if brands:
        brand_inner = await _brand_filter_sx(brands, selected_brands, ctx)
    brand_summary = await render_to_sx("market-desktop-brand-summary",
                              inner=SxExpr(brand_inner) if brand_inner else None)

    return "(<> " + " ".join([search_sx, cat_summary, brand_summary]) + ")"


async def _mobile_filter_summary_sx(ctx: dict) -> str:
    """Build mobile filter summary as sx."""
    asset_url_fn = ctx.get("asset_url")
    sort = ctx.get("sort", "")
    sort_options = ctx.get("sort_options", [])
    liked = ctx.get("liked", False)
    liked_count = ctx.get("liked_count", 0)
    selected_labels = ctx.get("selected_labels", [])
    selected_stickers = ctx.get("selected_stickers", [])
    selected_brands = ctx.get("selected_brands", [])
    labels = ctx.get("labels", [])
    stickers = ctx.get("stickers", [])
    brands = ctx.get("brands", [])

    # Search bar
    search_bar = await search_mobile_sx(ctx)

    # Summary chips showing active filters
    chip_parts: list[str] = []

    if sort and sort_options:
        for k, l, i in sort_options:
            if k == sort and callable(asset_url_fn):
                chip_parts.append(await render_to_sx("market-mobile-chip-sort", src=asset_url_fn(i), label=l))
    if liked:
        liked_parts = [await render_to_sx("market-mobile-chip-liked-icon")]
        if liked_count is not None:
            cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold"
            liked_parts.append(await render_to_sx("market-mobile-chip-count", cls=cls, count=str(liked_count)))
        liked_inner = "(<> " + " ".join(liked_parts) + ")"
        chip_parts.append(await render_to_sx("market-mobile-chip-liked", inner=SxExpr(liked_inner)))

    # Selected labels
    if selected_labels:
        label_item_parts = []
        for sl in selected_labels:
            for lb in labels:
                if lb.get("name") == sl and callable(asset_url_fn):
                    li_parts = [await render_to_sx(
                        "market-mobile-chip-image",
                        src=asset_url_fn("nav-labels/" + sl + ".svg"), name=sl,
                    )]
                    if lb.get("count") is not None:
                        cls = "text-[10px] text-stone-500" if lb["count"] != 0 else "text-md text-red-500 font-bold"
                        li_parts.append(await render_to_sx("market-mobile-chip-count", cls=cls, count=str(lb["count"])))
                    li_inner = "(<> " + " ".join(li_parts) + ")"
                    label_item_parts.append(await render_to_sx("market-mobile-chip-item", inner=SxExpr(li_inner)))
        if label_item_parts:
            label_items = "(<> " + " ".join(label_item_parts) + ")"
            chip_parts.append(await render_to_sx("market-mobile-chip-list", items=SxExpr(label_items)))

    # Selected stickers
    if selected_stickers:
        sticker_item_parts = []
        for ss in selected_stickers:
            for st in stickers:
                if st.get("name") == ss and callable(asset_url_fn):
                    si_parts = [await render_to_sx(
                        "market-mobile-chip-image",
                        src=asset_url_fn("stickers/" + ss + ".svg"), name=ss,
                    )]
                    if st.get("count") is not None:
                        cls = "text-[10px] text-stone-500" if st["count"] != 0 else "text-md text-red-500 font-bold"
                        si_parts.append(await render_to_sx("market-mobile-chip-count", cls=cls, count=str(st["count"])))
                    si_inner = "(<> " + " ".join(si_parts) + ")"
                    sticker_item_parts.append(await render_to_sx("market-mobile-chip-item", inner=SxExpr(si_inner)))
        if sticker_item_parts:
            sticker_items = "(<> " + " ".join(sticker_item_parts) + ")"
            chip_parts.append(await render_to_sx("market-mobile-chip-list", items=SxExpr(sticker_items)))

    # Selected brands
    if selected_brands:
        brand_item_parts = []
        for b in selected_brands:
            count = 0
            for br in brands:
                if br.get("name") == b:
                    count = br.get("count", 0)
            if count:
                brand_item_parts.append(await render_to_sx("market-mobile-chip-brand", name=b, count=str(count)))
            else:
                brand_item_parts.append(await render_to_sx("market-mobile-chip-brand-zero", name=b))
        brand_items = "(<> " + " ".join(brand_item_parts) + ")"
        chip_parts.append(await render_to_sx("market-mobile-chip-brand-list", items=SxExpr(brand_items)))

    chips_sx = "(<> " + " ".join(chip_parts) + ")" if chip_parts else '(<>)'
    chips_row = await render_to_sx("market-mobile-chips-row", inner=SxExpr(chips_sx))

    # Full mobile filter details
    from shared.utils import route_prefix
    prefix = route_prefix()
    mobile_filter = await _mobile_filter_content_sx(ctx, prefix)

    return await render_to_sx(
        "market-mobile-filter-summary",
        search_bar=SxExpr(search_bar),
        chips=SxExpr(chips_row),
        filter=SxExpr(mobile_filter),
    )


async def _mobile_filter_content_sx(ctx: dict, prefix: str) -> str:
    """Build the expanded mobile filter panel contents as sx."""
    selected_labels = ctx.get("selected_labels", [])
    selected_stickers = ctx.get("selected_stickers", [])
    selected_brands = ctx.get("selected_brands", [])
    current_local_href = ctx.get("current_local_href", "/")
    hx_select = ctx.get("hx_select_search", "#main-panel")
    sort_options = ctx.get("sort_options", [])
    sort = ctx.get("sort", "")
    liked = ctx.get("liked", False)
    liked_count = ctx.get("liked_count", 0)
    labels = ctx.get("labels", [])
    stickers = ctx.get("stickers", [])
    brands = ctx.get("brands", [])
    search = ctx.get("search", "")
    qs_fn = ctx.get("qs_filter")

    parts: list[str] = []

    # Sort options
    if sort_options:
        parts.append(await _sort_stickers_sx(sort_options, sort, ctx, mobile=True))

    # Clear filters button
    has_filters = search or selected_labels or selected_stickers or selected_brands
    if has_filters and callable(qs_fn):
        clear_url = prefix + current_local_href + qs_fn({"clear_filters": True})
        parts.append(await render_to_sx("market-mobile-clear-filters", href=clear_url, hx_select=hx_select))

    # Like + labels row
    like_label_parts = [await _like_filter_sx(liked, liked_count, ctx, mobile=True)]
    if labels:
        like_label_parts.append(await _labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels", mobile=True))
    like_labels_sx = "(<> " + " ".join(like_label_parts) + ")"
    parts.append(await render_to_sx("market-mobile-like-labels-row", inner=SxExpr(like_labels_sx)))

    # Stickers
    if stickers:
        parts.append(await _stickers_filter_sx(stickers, selected_stickers, ctx, mobile=True))

    # Brands
    if brands:
        parts.append(await _brand_filter_sx(brands, selected_brands, ctx, mobile=True))

    return "(<> " + " ".join(parts) + ")" if parts else "(<>)"


async def _sort_stickers_sx(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str:
    """Build sort option stickers as sx."""
    asset_url_fn = ctx.get("asset_url")
    current_local_href = ctx.get("current_local_href", "/")
    hx_select = ctx.get("hx_select_search", "#main-panel")
    qs_fn = ctx.get("qs_filter")
    from shared.utils import route_prefix
    prefix = route_prefix()

    item_parts: list[str] = []
    for k, label, icon in sort_options:
        if callable(qs_fn):
            href = prefix + current_local_href + qs_fn({"sort": k})
        else:
            href = "#"
        active = (k == current_sort)
        ring = " ring-2 ring-emerald-500 rounded" if active else ""
        src = asset_url_fn(icon) if callable(asset_url_fn) else icon
        item_parts.append(await render_to_sx(
            "market-filter-sort-item",
            href=href, hx_select=hx_select, ring_cls=ring, src=src, label=label,
        ))
    items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
    return await render_to_sx("market-filter-sort-row", items=SxExpr(items_sx))


async def _like_filter_sx(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str:
    """Build the like filter toggle as sx."""
    current_local_href = ctx.get("current_local_href", "/")
    hx_select = ctx.get("hx_select_search", "#main-panel")
    qs_fn = ctx.get("qs_filter")
    from shared.utils import route_prefix
    prefix = route_prefix()

    if callable(qs_fn):
        href = prefix + current_local_href + qs_fn({"liked": not liked})
    else:
        href = "#"

    icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400"
    size = "text-[40px]" if mobile else "text-2xl"
    return await render_to_sx(
        "market-filter-like",
        href=href, hx_select=hx_select, icon_cls=icon_cls, size_cls=size,
    )


async def _labels_filter_sx(labels: list, selected: list, ctx: dict, *,
                         prefix: str = "nav-labels", mobile: bool = False) -> str:
    """Build label filter buttons as sx."""
    asset_url_fn = ctx.get("asset_url")
    current_local_href = ctx.get("current_local_href", "/")
    hx_select = ctx.get("hx_select_search", "#main-panel")
    qs_fn = ctx.get("qs_filter")
    from shared.utils import route_prefix
    rp = route_prefix()

    item_parts: list[str] = []
    for lb in labels:
        name = lb.get("name", "")
        is_sel = name in selected
        if callable(qs_fn):
            new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
            href = rp + current_local_href + qs_fn({"labels": new_sel})
        else:
            href = "#"
        ring = " ring-2 ring-emerald-500 rounded" if is_sel else ""
        src = asset_url_fn(f"{prefix}/{name}.svg") if callable(asset_url_fn) else ""
        item_parts.append(await render_to_sx(
            "market-filter-label-item",
            href=href, hx_select=hx_select, ring_cls=ring, src=src, name=name,
        ))
    return "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"


async def _stickers_filter_sx(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str:
    """Build sticker filter grid as sx."""
    asset_url_fn = ctx.get("asset_url")
    current_local_href = ctx.get("current_local_href", "/")
    hx_select = ctx.get("hx_select_search", "#main-panel")
    qs_fn = ctx.get("qs_filter")
    from shared.utils import route_prefix
    rp = route_prefix()

    item_parts: list[str] = []
    for st in stickers:
        name = st.get("name", "")
        count = st.get("count", 0)
        is_sel = name in selected
        if callable(qs_fn):
            new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
            href = rp + current_local_href + qs_fn({"stickers": new_sel})
        else:
            href = "#"
        ring = " ring-2 ring-emerald-500 rounded" if is_sel else ""
        src = asset_url_fn(f"stickers/{name}.svg") if callable(asset_url_fn) else ""
        cls = "text-[10px] text-stone-500" if count != 0 else "text-md text-red-500 font-bold"
        item_parts.append(await render_to_sx(
            "market-filter-sticker-item",
            href=href, hx_select=hx_select, ring_cls=ring,
            src=src, name=name, count_cls=cls, count=str(count),
        ))
    items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
    return await render_to_sx("market-filter-stickers-row", items=SxExpr(items_sx))


async def _brand_filter_sx(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str:
    """Build brand filter checkboxes as sx."""
    current_local_href = ctx.get("current_local_href", "/")
    hx_select = ctx.get("hx_select_search", "#main-panel")
    qs_fn = ctx.get("qs_filter")
    from shared.utils import route_prefix
    rp = route_prefix()

    item_parts: list[str] = []
    for br in brands:
        name = br.get("name", "")
        count = br.get("count", 0)
        is_sel = name in selected
        if callable(qs_fn):
            new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
            href = rp + current_local_href + qs_fn({"brands": new_sel})
        else:
            href = "#"
        bg = " bg-yellow-200" if is_sel else ""
        cls = "text-md" if count else "text-md text-red-500"
        item_parts.append(await render_to_sx(
            "market-filter-brand-item",
            href=href, hx_select=hx_select, bg_cls=bg,
            name_cls=cls, name=name, count=str(count),
        ))
    items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
    return await render_to_sx("market-filter-brands-panel", items=SxExpr(items_sx))


async def _subcategory_selector_sx(subs: list, top_href: str, current_sub: str, ctx: dict) -> str:
    """Build subcategory vertical nav as sx."""
    hx_select = ctx.get("hx_select_search", "#main-panel")
    from shared.utils import route_prefix
    rp = route_prefix()

    all_cls = " bg-stone-200 font-medium" if not current_sub else ""
    all_full_href = rp + top_href
    item_parts = [await render_to_sx(
        "market-filter-subcategory-item",
        href=all_full_href, hx_select=hx_select, active_cls=all_cls, name="All",
    )]
    for sub in subs:
        slug = sub.get("slug", "")
        name = sub.get("name", "")
        href = sub.get("href", "")
        active = (slug == current_sub)
        active_cls = " bg-stone-200 font-medium" if active else ""
        full_href = rp + href
        item_parts.append(await render_to_sx(
            "market-filter-subcategory-item",
            href=full_href, hx_select=hx_select, active_cls=active_cls, name=name,
        ))
    items_sx = "(<> " + " ".join(item_parts) + ")"
    return await render_to_sx("market-filter-subcategory-panel", items=SxExpr(items_sx))


# ---------------------------------------------------------------------------
# Product detail page content
# ---------------------------------------------------------------------------

async def _product_detail_sx(d: dict, ctx: dict) -> str:
    """Build product detail main panel content as sx."""
    from quart import url_for
    from shared.browser.app.csrf import generate_csrf_token

    asset_url_fn = ctx.get("asset_url")
    user = ctx.get("user")
    liked_by_current_user = ctx.get("liked_by_current_user", False)
    csrf = generate_csrf_token()

    images = d.get("images", [])
    labels = d.get("labels", [])
    stickers = d.get("stickers", [])
    brand = d.get("brand", "")
    slug = d.get("slug", "")

    # Gallery
    if images:
        # Like button
        like_sx = ""
        if user:
            like_sx = await _like_button_sx(slug, liked_by_current_user, csrf, ctx)

        # Main image + labels
        label_parts: list[str] = []
        if callable(asset_url_fn):
            for l in labels:
                label_parts.append(await render_to_sx(
                    "market-label-overlay",
                    src=asset_url_fn("labels/" + l + ".svg"),
                ))
        labels_sx = "(<> " + " ".join(label_parts) + ")" if label_parts else None

        gallery_inner = await render_to_sx(
            "market-detail-gallery-inner",
            like=SxExpr(like_sx) if like_sx else None,
            image=images[0], alt=d.get("title", ""),
            labels=SxExpr(labels_sx) if labels_sx else None,
            brand=brand,
        )

        # Prev/next buttons
        nav_buttons = ""
        if len(images) > 1:
            nav_buttons = await render_to_sx("market-detail-nav-buttons")

        gallery_sx = await render_to_sx(
            "market-detail-gallery",
            inner=SxExpr(gallery_inner),
            nav=SxExpr(nav_buttons) if nav_buttons else None,
        )

        # Thumbnails
        gallery_parts = [gallery_sx]
        if len(images) > 1:
            thumb_parts = []
            for i, u in enumerate(images):
                thumb_parts.append(await render_to_sx(
                    "market-detail-thumb",
                    title=f"Image {i+1}", src=u, alt=f"thumb {i+1}",
                ))
            thumbs_sx = "(<> " + " ".join(thumb_parts) + ")"
            gallery_parts.append(await render_to_sx("market-detail-thumbs", thumbs=SxExpr(thumbs_sx)))
        gallery_final = "(<> " + " ".join(gallery_parts) + ")"
    else:
        like_sx = ""
        if user:
            like_sx = await _like_button_sx(slug, liked_by_current_user, csrf, ctx)
        gallery_final = await render_to_sx("market-detail-no-image",
                                  like=SxExpr(like_sx) if like_sx else None)

    # Stickers below gallery
    stickers_sx = ""
    if stickers and callable(asset_url_fn):
        sticker_parts = []
        for s in stickers:
            sticker_parts.append(await render_to_sx(
                "market-detail-sticker",
                src=asset_url_fn("stickers/" + s + ".svg"), name=s,
            ))
        sticker_items_sx = "(<> " + " ".join(sticker_parts) + ")"
        stickers_sx = await render_to_sx("market-detail-stickers", items=SxExpr(sticker_items_sx))

    # Right column: prices, description, sections
    pr = _set_prices(d)
    detail_parts: list[str] = []

    # Unit price / case size extras
    extra_parts: list[str] = []
    ppu = d.get("price_per_unit") or d.get("price_per_unit_raw")
    if ppu:
        extra_parts.append(await render_to_sx(
            "market-detail-unit-price",
            price=_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency")),
        ))
    if d.get("case_size_raw"):
        extra_parts.append(await render_to_sx("market-detail-case-size", size=d["case_size_raw"]))
    if extra_parts:
        extras_sx = "(<> " + " ".join(extra_parts) + ")"
        detail_parts.append(await render_to_sx("market-detail-extras", inner=SxExpr(extras_sx)))

    # Description
    desc_short = d.get("description_short")
    desc_html_val = d.get("description_html")
    if desc_short or desc_html_val:
        desc_parts: list[str] = []
        if desc_short:
            desc_parts.append(await render_to_sx("market-detail-desc-short", text=desc_short))
        if desc_html_val:
            desc_parts.append(await render_to_sx("market-detail-desc-html", html=desc_html_val))
        desc_inner = "(<> " + " ".join(desc_parts) + ")"
        detail_parts.append(await render_to_sx("market-detail-desc-wrapper", inner=SxExpr(desc_inner)))

    # Sections (expandable)
    sections = d.get("sections", [])
    if sections:
        sec_parts = []
        for sec in sections:
            sec_parts.append(await render_to_sx(
                "market-detail-section",
                title=sec.get("title", ""), html=sec.get("html", ""),
            ))
        sec_items_sx = "(<> " + " ".join(sec_parts) + ")"
        detail_parts.append(await render_to_sx("market-detail-sections", items=SxExpr(sec_items_sx)))

    details_inner_sx = "(<> " + " ".join(detail_parts) + ")" if detail_parts else "(<>)"
    details_sx = await render_to_sx("market-detail-right-col", inner=SxExpr(details_inner_sx))

    return await render_to_sx(
        "market-detail-layout",
        gallery=SxExpr(gallery_final),
        stickers=SxExpr(stickers_sx) if stickers_sx else None,
        details=SxExpr(details_sx),
    )


# ---------------------------------------------------------------------------
# Product meta (OpenGraph, JSON-LD)
# ---------------------------------------------------------------------------

async def _product_meta_sx(d: dict, ctx: dict) -> str:
    """Build product meta tags as sx (auto-hoisted to <head> by sx.js)."""
    import json
    from quart import request

    title = d.get("title", "")
    desc_source = d.get("description_short") or ""
    if not desc_source and d.get("description_html"):
        import re
        desc_source = re.sub(r"<[^>]+>", "", d.get("description_html", ""))
    description = desc_source.strip().replace("\n", " ")[:160]
    image_url = d.get("image") or (d.get("images", [None])[0] if d.get("images") else None)
    canonical = request.url if request else ""
    brand = d.get("brand", "")
    sku = d.get("sku", "")
    price = d.get("special_price") or d.get("regular_price") or d.get("rrp")
    price_currency = d.get("special_price_currency") or d.get("regular_price_currency") or d.get("rrp_currency")

    parts = [await render_to_sx("market-meta-title", title=title)]
    parts.append(await render_to_sx("market-meta-description", description=description))
    if canonical:
        parts.append(await render_to_sx("market-meta-canonical", href=canonical))

    # OpenGraph
    site_title = ctx.get("base_title", "")
    parts.append(await render_to_sx("market-meta-og", property="og:site_name", content=site_title))
    parts.append(await render_to_sx("market-meta-og", property="og:type", content="product"))
    parts.append(await render_to_sx("market-meta-og", property="og:title", content=title))
    parts.append(await render_to_sx("market-meta-og", property="og:description", content=description))
    if canonical:
        parts.append(await render_to_sx("market-meta-og", property="og:url", content=canonical))
    if image_url:
        parts.append(await render_to_sx("market-meta-og", property="og:image", content=image_url))
    if price and price_currency:
        parts.append(await render_to_sx("market-meta-og", property="product:price:amount", content=f"{price:.2f}"))
        parts.append(await render_to_sx("market-meta-og", property="product:price:currency", content=price_currency))
    if brand:
        parts.append(await render_to_sx("market-meta-og", property="product:brand", content=brand))

    # Twitter
    card_type = "summary_large_image" if image_url else "summary"
    parts.append(await render_to_sx("market-meta-twitter", name="twitter:card", content=card_type))
    parts.append(await render_to_sx("market-meta-twitter", name="twitter:title", content=title))
    parts.append(await render_to_sx("market-meta-twitter", name="twitter:description", content=description))
    if image_url:
        parts.append(await render_to_sx("market-meta-twitter", name="twitter:image", content=image_url))

    # JSON-LD
    jsonld = {
        "@context": "https://schema.org",
        "@type": "Product",
        "name": title,
        "image": image_url,
        "description": description,
        "sku": sku,
        "url": canonical,
    }
    if brand:
        jsonld["brand"] = {"@type": "Brand", "name": brand}
    if price and price_currency:
        jsonld["offers"] = {
            "@type": "Offer",
            "price": price,
            "priceCurrency": price_currency,
            "url": canonical,
            "availability": "https://schema.org/InStock",
        }
    parts.append(await render_to_sx("market-meta-jsonld", json=json.dumps(jsonld)))

    return "(<> " + " ".join(parts) + ")"


# ---------------------------------------------------------------------------
# Market cards (all markets / page markets)
# ---------------------------------------------------------------------------

async def _market_card_sx(market: Any, page_info: dict, *, show_page_badge: bool = True,
                      post_slug: str = "") -> str:
    """Build a single market card as sx."""
    from shared.infrastructure.urls import market_url

    name = getattr(market, "name", "")
    description = getattr(market, "description", "")
    slug = getattr(market, "slug", "")
    container_id = getattr(market, "container_id", None)

    if show_page_badge and page_info:
        pi = page_info.get(container_id, {})
        p_slug = pi.get("slug", "")
        p_title = pi.get("title", "")
        market_href = market_url(f"/{p_slug}/{slug}/") if p_slug else ""
    else:
        p_slug = post_slug
        p_title = ""
        market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else ""

    title_sx = ""
    if market_href:
        title_sx = await render_to_sx("market-market-card-title-link", href=market_href, name=name)
    else:
        title_sx = await render_to_sx("market-market-card-title", name=name)

    desc_sx = ""
    if description:
        desc_sx = await render_to_sx("market-market-card-desc", description=description)

    badge_sx = ""
    if show_page_badge and p_title:
        badge_href = market_url(f"/{p_slug}/")
        badge_sx = await render_to_sx("market-market-card-badge", href=badge_href, title=p_title)

    return await render_to_sx(
        "market-market-card",
        title_content=SxExpr(title_sx) if title_sx else None,
        desc_content=SxExpr(desc_sx) if desc_sx else None,
        badge_content=SxExpr(badge_sx) if badge_sx else None,
    )


async def _market_cards_sx(markets: list, page_info: dict, page: int, has_more: bool,
                        next_url: str, *, show_page_badge: bool = True,
                        post_slug: str = "") -> str:
    """Build market cards with infinite scroll sentinel as sx."""
    parts = []
    for m in markets:
        parts.append(await _market_card_sx(m, page_info, show_page_badge=show_page_badge,
                                post_slug=post_slug))
    if has_more:
        parts.append(await render_to_sx(
            "sentinel-simple",
            id=f"sentinel-{page}", next_url=next_url,
        ))
    return "(<> " + " ".join(parts) + ")"


async def _markets_grid(cards_sx: str) -> str:
    """Wrap market cards in a grid as sx."""
    return await render_to_sx("market-markets-grid", cards=SxExpr(cards_sx))


async def _no_markets_sx(message: str = "No markets available") -> str:
    """Empty state for markets as sx."""
    return await render_to_sx("empty-state", icon="fa fa-store", message=message,
                    cls="px-3 py-12 text-center text-stone-400")


# ---------------------------------------------------------------------------
# Market landing page
# ---------------------------------------------------------------------------

async def _market_landing_content_sx(post: dict) -> str:
    """Build market landing page content as sx."""
    parts: list[str] = []
    if post.get("custom_excerpt"):
        parts.append(await render_to_sx("market-landing-excerpt", text=post["custom_excerpt"]))
    if post.get("feature_image"):
        parts.append(await render_to_sx("market-landing-image", src=post["feature_image"]))
    if post.get("html"):
        parts.append(await render_to_sx("market-landing-html", html=post["html"]))
    inner = "(<> " + " ".join(parts) + ")" if parts else "(<>)"
    return await render_to_sx("market-landing-content", inner=SxExpr(inner))


# ---------------------------------------------------------------------------
# Browse page
# ---------------------------------------------------------------------------

async def _product_grid(cards_sx: str) -> str:
    """Wrap product cards in a grid as sx."""
    return await render_to_sx("market-product-grid", cards=SxExpr(cards_sx))


async def render_browse_page(ctx: dict) -> str:
    """Full page: product browse with filters."""
    cards = await _product_cards_sx(ctx)
    content = await _product_grid(cards)

    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
    hdr = await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx),
        post_header=SxExpr(await _post_header_sx(ctx)),
        market_header=SxExpr(await _market_header_sx(ctx)))
    menu = await _mobile_nav_panel_sx(ctx)
    filter_sx = await _mobile_filter_summary_sx(ctx)
    aside_sx = await _desktop_filter_sx(ctx)

    return await full_page_sx(ctx, header_rows=hdr, content=content,
                          menu=menu, filter=filter_sx, aside=aside_sx)


async def render_browse_oob(ctx: dict) -> str:
    """OOB response: product browse."""
    cards = await _product_cards_sx(ctx)
    content = await _product_grid(cards)

    oob_hdr = await _oob_header_sx("post-header-child", "market-header-child",
                             await _market_header_sx(ctx))
    oobs = await render_to_sx("market-browse-layout-oob",
        oob_header=SxExpr(oob_hdr),
        post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
        clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
                               "market-row", "market-header-child")))
    menu = await _mobile_nav_panel_sx(ctx)
    filter_sx = await _mobile_filter_summary_sx(ctx)
    aside_sx = await _desktop_filter_sx(ctx)

    return await oob_page_sx(oobs=oobs, content=content,
                         menu=menu, filter=filter_sx, aside=aside_sx)


async def render_browse_cards(ctx: dict) -> str:
    """Pagination fragment: product cards -- sx wire format."""
    return await _product_cards_sx(ctx)


# ---------------------------------------------------------------------------
# Product detail
# ---------------------------------------------------------------------------

async def render_product_page(ctx: dict, d: dict) -> str:
    """Full page: product detail."""
    content = await _product_detail_sx(d, ctx)
    meta = await _product_meta_sx(d, ctx)

    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
    hdr = await render_to_sx_with_env("market-product-layout-full", _ctx_to_env(ctx),
        post_header=SxExpr(await _post_header_sx(ctx)),
        market_header=SxExpr(await _market_header_sx(ctx)),
        product_header=SxExpr(await _product_header_sx(ctx, d)))
    return await full_page_sx(ctx, header_rows=hdr, content=content, meta=meta)


async def render_product_oob(ctx: dict, d: dict) -> str:
    """OOB response: product detail."""
    content = await _product_detail_sx(d, ctx)

    oobs = await render_to_sx("market-oob-wrap",
        parts=SxExpr("(<> " + await _market_header_sx(ctx, oob=True) + " "
            + await _oob_header_sx("market-header-child", "product-header-child",
                              await _product_header_sx(ctx, d)) + " "
            + _clear_deeper_oob("post-row", "post-header-child",
                               "market-row", "market-header-child",
                               "product-row", "product-header-child") + ")"))
    menu = await _mobile_nav_panel_sx(ctx)
    return await oob_page_sx(oobs=oobs, content=content, menu=menu)


# ---------------------------------------------------------------------------
# Product admin
# ---------------------------------------------------------------------------

async def render_product_admin_page(ctx: dict, d: dict) -> str:
    """Full page: product admin."""
    content = await _product_detail_sx(d, ctx)

    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
    hdr = await render_to_sx_with_env("market-product-admin-layout-full", _ctx_to_env(ctx),
        post_header=SxExpr(await _post_header_sx(ctx)),
        market_header=SxExpr(await _market_header_sx(ctx)),
        product_header=SxExpr(await _product_header_sx(ctx, d)),
        admin_header=SxExpr(await _product_admin_header_sx(ctx, d)))
    return await full_page_sx(ctx, header_rows=hdr, content=content)


async def render_product_admin_oob(ctx: dict, d: dict) -> str:
    """OOB response: product admin."""
    content = await _product_detail_sx(d, ctx)

    oobs = await render_to_sx("market-oob-wrap",
        parts=SxExpr("(<> " + await _product_header_sx(ctx, d, oob=True) + " "
            + await _oob_header_sx("product-header-child", "product-admin-header-child",
                              await _product_admin_header_sx(ctx, d)) + " "
            + _clear_deeper_oob("post-row", "post-header-child",
                               "market-row", "market-header-child",
                               "product-row", "product-header-child",
                               "product-admin-row", "product-admin-header-child") + ")"))
    return await oob_page_sx(oobs=oobs, content=content)


async def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
    """Build product admin header row as sx."""
    from quart import url_for

    slug = d.get("slug", "")
    link_href = url_for("market.browse.product.admin", product_slug=slug)
    return await render_to_sx(
        "menu-row-sx",
        id="product-admin-row", level=4,
        link_href=link_href, link_label="admin!!", icon="fa fa-cog",
        child_id="product-admin-header-child", oob=oob,
    )


# ---------------------------------------------------------------------------
# Market admin
# ---------------------------------------------------------------------------

async def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
    """Build market admin header row -- delegates to shared helper."""
    slug = (ctx.get("post") or {}).get("slug", "")
    return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)


# ---------------------------------------------------------------------------
# Page admin (/<slug>/admin/) -- post-level admin for markets
# ---------------------------------------------------------------------------

async def _markets_admin_panel_sx(ctx: dict) -> str:
    """Render the markets list + create form panel."""
    from quart import g, url_for
    from shared.services.registry import services

    rights = ctx.get("rights") or {}
    is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
    has_access = ctx.get("has_access")
    can_create = has_access("page_admin.create_market") if callable(has_access) else is_admin
    csrf_token = ctx.get("csrf_token")
    csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")

    post = ctx.get("post") or {}
    post_id = post.get("id")
    markets = await services.market.marketplaces_for_container(g.s, "page", post_id) if post_id else []

    form_html = ""
    if can_create:
        create_url = url_for("page_admin.create_market")
        form_html = await render_to_sx("crud-create-form",
                           create_url=create_url, csrf=csrf,
                           errors_id="market-create-errors",
                           list_id="markets-list",
                           placeholder="e.g. Suma, Craft Fair",
                           btn_label="Add market")

    list_html = await _markets_admin_list_sx(ctx, markets)
    return await render_to_sx("crud-panel",
                  form=SxExpr(form_html), list=SxExpr(list_html),
                  list_id="markets-list")


async def _markets_admin_list_sx(ctx: dict, markets: list) -> str:
    """Render the markets list items."""
    from quart import url_for
    from shared.utils import route_prefix
    csrf_token = ctx.get("csrf_token")
    csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
    prefix = route_prefix()

    if not markets:
        return await render_to_sx("empty-state",
                        message="No markets yet. Create one above.",
                        cls="text-gray-500 mt-4")

    parts = []
    for m in markets:
        m_slug = getattr(m, "slug", "") or (m.get("slug", "") if isinstance(m, dict) else "")
        m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "")
        post_slug = (ctx.get("post") or {}).get("slug", "")
        href = prefix + f"/{post_slug}/{m_slug}/"
        del_url = url_for("page_admin.delete_market", market_slug=m_slug)
        csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
        parts.append(await render_to_sx("crud-item",
                            href=href, name=m_name, slug=m_slug,
                            del_url=del_url, csrf_hdr=csrf_hdr,
                            list_id="markets-list",
                            confirm_title="Delete market?",
                            confirm_text="Products will be hidden (soft delete)"))
    return "".join(parts)


async def render_markets_admin_list_panel(ctx: dict) -> str:
    """Render the markets admin panel HTML for POST/DELETE response."""
    return await _markets_admin_panel_sx(ctx)


# ---------------------------------------------------------------------------
# Public API: POST handler fragment renderers
# ---------------------------------------------------------------------------

async def render_all_markets_cards(markets: list, has_more: bool,
                                     page_info: dict, page: int) -> str:
    """Pagination fragment: all markets cards."""
    from quart import url_for
    from shared.utils import route_prefix

    prefix = route_prefix()
    next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
    return await _market_cards_sx(markets, page_info, page, has_more, next_url)


async def render_page_markets_cards(markets: list, has_more: bool,
                                      page: int, post_slug: str) -> str:
    """Pagination fragment: page-scoped markets cards."""
    from quart import url_for
    from shared.utils import route_prefix

    prefix = route_prefix()
    next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
    return await _market_cards_sx(markets, {}, page, has_more, next_url,
                               show_page_badge=False, post_slug=post_slug)


async def render_like_toggle_button(slug: str, liked: bool, *,
                               like_url: str | None = None,
                               item_type: str = "product") -> str:
    """Render a standalone like toggle button for HTMX POST response."""
    from shared.browser.app.csrf import generate_csrf_token
    from quart import url_for
    from shared.utils import host_url

    csrf = generate_csrf_token()
    if not like_url:
        like_url = host_url(url_for("market.browse.product.like_toggle", product_slug=slug))

    if liked:
        colour = "text-red-600"
        icon = "fa-solid fa-heart"
        label = f"Unlike this {item_type}"
    else:
        colour = "text-stone-300"
        icon = "fa-regular fa-heart"
        label = f"Like this {item_type}"

    return await render_to_sx(
        "market-like-toggle-button",
        colour=colour, action=like_url,
        hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
        label=label, icon_cls=icon,
    )


async def render_cart_added_response(cart: list, item: Any, d: dict) -> str:
    """Render the HTMX response after add-to-cart.

    Returns OOB fragments: cart-mini icon + product add/remove buttons + cart item row.
    """
    from shared.browser.app.csrf import generate_csrf_token
    from quart import url_for, g
    from shared.infrastructure.urls import cart_url as _cart_url

    csrf = generate_csrf_token()
    slug = d.get("slug", "")
    count = sum(getattr(ci, "quantity", 0) for ci in cart)

    # 1. Cart mini icon OOB
    if count > 0:
        cart_href = _cart_url("/")
        cart_mini = await render_to_sx("market-cart-mini-count", href=cart_href, count=str(count))
    else:
        from shared.config import config
        blog_href = config().get("blog_url", "/")
        logo = config().get("logo", "")
        cart_mini = await render_to_sx("market-cart-mini-empty", href=blog_href, logo=logo)

    # 2. Add/remove buttons OOB
    action = url_for("market.browse.product.cart", product_slug=slug)
    quantity = getattr(item, "quantity", 0) if item else 0
    if not quantity:
        cart_add = await render_to_sx(
            "market-cart-add-empty",
            cart_id=f"cart-{slug}", action=action, csrf=csrf,
        )
    else:
        cart_href = _cart_url("/") if callable(_cart_url) else "/"
        cart_add = await render_to_sx(
            "market-cart-add-quantity",
            cart_id=f"cart-{slug}", action=action, csrf=csrf,
            minus_val=str(quantity - 1), plus_val=str(quantity + 1),
            quantity=str(quantity), cart_href=cart_href,
        )
    add_sx = await render_to_sx(
        "market-cart-add-oob",
        id=f"cart-add-{slug}",
        inner=SxExpr(cart_add),
    )

    return "(<> " + cart_mini + " " + add_sx + ")"


# ===========================================================================
# Layouts
# ===========================================================================

def _register_market_layouts() -> None:
    from shared.sx.layouts import register_custom_layout
    register_custom_layout("market", _market_full, _market_oob, _market_mobile)
    register_custom_layout("market-admin", _market_admin_full, _market_admin_oob)


async def _market_full(ctx: dict, **kw: Any) -> str:
    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
    return await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx),
        post_header=SxExpr(await _post_header_sx(ctx)),
        market_header=SxExpr(await _market_header_sx(ctx)))


async def _market_oob(ctx: dict, **kw: Any) -> str:
    oob_hdr = await _oob_header_sx("post-header-child", "market-header-child",
                             await _market_header_sx(ctx))
    return await render_to_sx("market-browse-layout-oob",
        oob_header=SxExpr(oob_hdr),
        post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
        clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
                               "market-row", "market-header-child")))


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


async def _market_admin_full(ctx: dict, **kw: Any) -> str:
    from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
    selected = kw.get("selected", "")
    return await render_to_sx_with_env("market-admin-layout-full", _ctx_to_env(ctx),
        post_header=SxExpr(await _post_header_sx(ctx)),
        market_header=SxExpr(await _market_header_sx(ctx)),
        admin_header=SxExpr(await _market_admin_header_sx(ctx, selected=selected)))


async def _market_admin_oob(ctx: dict, **kw: Any) -> str:
    selected = kw.get("selected", "")
    return await render_to_sx("market-admin-layout-oob",
        market_header_oob=SxExpr(await _market_header_sx(ctx, oob=True)),
        admin_oob_header=SxExpr(await _oob_header_sx("market-header-child", "market-admin-header-child",
                              await _market_admin_header_sx(ctx, selected=selected))),
        clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
                               "market-row", "market-header-child",
                               "market-admin-row", "market-admin-header-child")))


# ===========================================================================
# Page helpers
# ===========================================================================

def _register_market_helpers() -> None:
    from shared.sx.pages import register_page_helpers

    register_page_helpers("market", {
        "all-markets-content": _h_all_markets_content,
        "page-markets-content": _h_page_markets_content,
        "page-admin-content": _h_page_admin_content,
        "market-home-content": _h_market_home_content,
        "market-admin-content": _h_market_admin_content,
    })


async def _h_all_markets_content(**kw):
    from quart import g, url_for, request
    from shared.utils import route_prefix
    from shared.services.registry import services
    from shared.infrastructure.data_client import fetch_data
    from shared.contracts.dtos import PostDTO, dto_from_dict

    page = int(request.args.get("page", 1))
    markets, has_more = await services.market.list_marketplaces(
        g.s, page=page, per_page=20,
    )

    page_info = {}
    if markets:
        post_ids = list({m.container_id for m in markets if m.container_type == "page"})
        if post_ids:
            raw_posts = await fetch_data("blog", "posts-by-ids",
                                         params={"ids": ",".join(str(i) for i in post_ids)},
                                         required=False) or []
            for raw_p in raw_posts:
                p = dto_from_dict(PostDTO, raw_p)
                page_info[p.id] = {"title": p.title, "slug": p.slug}

    if not markets:
        return await _no_markets_sx()

    prefix = route_prefix()
    next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)

    cards = await _market_cards_sx(markets, page_info, page, has_more, next_url)
    content = await _markets_grid(cards)
    return "(<> " + content + " " + '(div :class "pb-8")' + ")"


async def _h_page_markets_content(slug=None, **kw):
    from quart import g, url_for, request
    from shared.utils import route_prefix
    from shared.services.registry import services

    post = g.post_data["post"]
    page = int(request.args.get("page", 1))
    markets, has_more = await services.market.list_marketplaces(
        g.s, "page", post["id"], page=page, per_page=20,
    )
    post_slug = post.get("slug", "")

    if not markets:
        return await _no_markets_sx("No markets for this page")

    prefix = route_prefix()
    next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)

    cards = await _market_cards_sx(markets, {}, page, has_more, next_url,
                                show_page_badge=False, post_slug=post_slug)
    content = await _markets_grid(cards)
    return "(<> " + content + " " + '(div :class "pb-8")' + ")"


async def _h_page_admin_content(slug=None, **kw):
    from shared.sx.page import get_template_context
    ctx = await get_template_context()
    content = await _markets_admin_panel_sx(ctx)
    return '(div :id "main-panel" ' + content + ')'


async def _h_market_home_content(page_slug=None, market_slug=None, **kw):
    from quart import g
    post_data = getattr(g, "post_data", {})
    post = post_data.get("post", {})
    return await _market_landing_content_sx(post)


def _h_market_admin_content(page_slug=None, market_slug=None, **kw):
    return '"market admin"'
