"""Server-rendered SEO layer.

The app is a client-rendered SPA, but every page's HTML leaves through FastAPI,
so the whole machine-readable layer -- title, description, canonical, social
tags and schema.org JSON-LD -- is stamped into the <head> server-side before a
single line of JavaScript runs. Crawlers that never execute JS still see all of
it. Product pages also get their BODY rendered here (`product_body`): the same
H1, meta line, awards, verdict and price table the SPA draws, with the SPA's
own class names, so a client that runs no JavaScript reads the page and one
that does sees no change when React mounts over it. The rule is that the served
markup shows exactly what the SPA shows in its default state (no airports
chosen); anything else is cloaking. The detail object is also seeded into the
page as `window.__DFP_PRODUCT__`, so the SPA's first commit is the finished
page rather than its skeleton.

Everything injected here is built from untrusted strings (product names come
from retailer feeds), so escaping is not optional: HTML attributes are
entity-escaped, and JSON-LD forbids a raw "<" so a name containing
"</script>" cannot break out of its tag.
"""

import html
import json
import re
from collections.abc import Collection
from datetime import UTC, datetime

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.config import settings
from app.models import Listing, Location, PriceObservation, Product
from app.models.editorial import ArticleOut
from app.models.hubs import AirportDetail, BrandDetail, DatasetFacts, AirportCategoryDetail
from app.models.schemas import AwardOut, PriceOut, ProductDetail, ProductSummary
from app.services import catalog_queries, editorial

# URL shapes live in urls.py (mirrored by lib/urls.ts); re-exported here because
# the routes and tests have always imported them from the SEO layer.
from app.services.urls import (  # noqa: F401
    airport_path,
    airport_query,
    airport_tab,
    brand_page_path,
    brand_path,
    category_path,
    parse_airport_slug,
    parse_product_slug,
    product_path,
    airport_category_path,
    category_from_slug,
    feature_flags,
    savings_from_path,
    slugify,
)

SITE_NAME = "Duty Free Professor"
FEED_TITLE = f"{SITE_NAME}: new in duty free"
DEFAULT_TITLE = "Duty Free Professor: compare airport duty-free prices"
DEFAULT_DESCRIPTION = (
    "Compare duty-free prices across airport shops worldwide. "
    "Set your trip and see where along your route it's cheapest to buy."
)

_HOST_RE = re.compile(r"^[a-z0-9.-]+(:\d+)?$")
#: The SPA's empty mount point in the built shell; product_body fills it.
ROOT_SLOT = '<div id="root"></div>'


def public_base(host_header: str | None) -> str:
    """An origin derived from the Host header the proxy passed through."""
    host = (host_header or "").lower()
    if not _HOST_RE.match(host):
        return ""
    scheme = "http" if host.startswith(("127.", "172.", "192.168.", "localhost")) else "https"
    return f"{scheme}://{host}"


def site_base(host_header: str | None, configured: str, production: bool) -> str:
    """The origin every absolute URL is built from.

    Configured (`PUBLIC_BASE_URL`) wins. Without it, a dev or test process may
    trust the Host header; a production one must not, and emits relative URLs
    until the value is set (the startup log says so).
    """
    if configured:
        return configured.rstrip("/")
    return "" if production else public_base(host_header)


# JSON-LD keys whose values are URLs on this site. Payloads are written with
# site-relative paths and made absolute at apply time, so the static heads can
# be module constants and every URL still carries the configured origin.
_URL_KEYS = {"url", "item", "@id", "logo", "image", "target"}


def absolutise(payload, base: str):
    """Prefix site-relative URL values in a JSON-LD payload with the origin."""
    if isinstance(payload, dict):
        return {
            k: (base + v if k in _URL_KEYS and isinstance(v, str) and v.startswith("/") else absolutise(v, base))
            for k, v in payload.items()
        }
    if isinstance(payload, list):
        return [absolutise(v, base) for v in payload]
    return payload


def _jsonld(payload: dict) -> str:
    # < keeps any "</script>" inside a value from terminating the tag.
    body = json.dumps(payload, ensure_ascii=False).replace("<", "\\u003c")
    return f'<script type="application/ld+json">{body}</script>'


class Head:
    """One page's injected head content."""

    def __init__(
        self,
        title: str,
        description: str,
        canonical_path: str | None = None,
        jsonld: list[dict] | None = None,
        noindex: bool = False,
        image: str | None = None,
        preload_image: str | None = None,
        body: str | None = None,
        seed: dict | None = None,
        last_modified: datetime | None = None,
        seed_key: str = "__DFP_PRODUCT__",
        og_type: str = "website",
    ) -> None:
        self.title = title
        #: Open Graph type: "website" for every page but an article.
        self.og_type = og_type
        self.description = description
        self.preload_image = preload_image
        self.canonical_path = canonical_path
        self.jsonld = jsonld or []
        self.noindex = noindex
        self.image = image
        #: Server-rendered markup for the SPA's root element (see product_body).
        self.body = body
        #: The page's data object, seeded for the SPA so it need not refetch it,
        #: under `window.<seed_key>` (one key per page type; queries.ts reads them).
        self.seed = seed
        self.seed_key = seed_key
        #: When the page's data last changed (the HTTP Last-Modified); None = process start.
        self.last_modified = last_modified

    def apply(self, template: str, base: str) -> str:
        """Stamp this head into the built index.html."""
        page = re.sub(
            r"<title>.*?</title>", f"<title>{html.escape(self.title)}</title>", template, count=1
        )
        page = re.sub(
            r'<meta name="description" content="[^"]*"',
            f'<meta name="description" content="{html.escape(self.description, quote=True)}"',
            page,
            count=1,
        )
        extra: list[str] = []
        if self.noindex:
            extra.append('<meta name="robots" content="noindex, nofollow" />')
        canonical = f"{base}{self.canonical_path}" if base and self.canonical_path else None
        if canonical:
            extra.append(f'<link rel="canonical" href="{html.escape(canonical, quote=True)}" />')
            extra.append(
                f'<meta property="og:url" content="{html.escape(canonical, quote=True)}" />'
            )
        if base:
            extra.append(
                f'<link rel="alternate" type="application/rss+xml" title="{html.escape(FEED_TITLE, quote=True)}" '
                f'href="{html.escape(base + "/feed.xml", quote=True)}" />'
            )
        extra.append(f'<meta property="og:site_name" content="{html.escape(SITE_NAME)}" />')
        extra.append(f'<meta property="og:title" content="{html.escape(self.title, quote=True)}" />')
        extra.append(
            f'<meta property="og:description" content="{html.escape(self.description, quote=True)}" />'
        )
        extra.append(f'<meta property="og:type" content="{html.escape(self.og_type, quote=True)}" />')
        if self.image:
            extra.append(f'<meta property="og:image" content="{html.escape(self.image, quote=True)}" />')
        if self.preload_image:
            # The LCP element is only discovered after the SPA fetches its
            # data; preloading it from the head starts the download at parse
            # time instead.
            extra.append(
                f'<link rel="preload" as="image" href="{html.escape(self.preload_image, quote=True)}"'
                ' fetchpriority="high" />'
            )
        for payload in self.jsonld:
            extra.append(_jsonld(absolutise(payload, base)))
        if self.seed is not None:
            seed = json.dumps(self.seed, ensure_ascii=False).replace("<", "\\u003c")
            extra.append(f"<script>window.{self.seed_key} = {seed}</script>")
        page = page.replace("</head>", "    " + "\n    ".join(extra) + "\n  </head>", 1)
        if self.body:
            page = page.replace(ROOT_SLOT, f'<div id="root">{self.body}</div>', 1)
        return page


def _truncate(text: str, limit: int = 155) -> str:
    return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"


def product_head(
    db: Session, product_id: int, medal_assets: Collection[str] = ()
) -> Head | None:
    """Title, description, schema.org Product markup and the rendered body for one bottle.

    Everything derives from the same `ProductDetail` the API hands the SPA, so
    the head, the body and the page a user sees cannot disagree.
    """
    detail = catalog_queries.get_product(db, product_id)
    if detail is None:
        return None
    return head_for_detail(detail, medal_assets)


def gtin_property(gtin: str | None) -> str | None:
    """schema.org names the barcode property by its width; a 12-digit UPC
    published as gtin13 fails validation, and the generic `gtin` is ignored by
    the checkers that matter."""
    if not gtin or not gtin.isdigit():
        return None
    return {8: "gtin8", 12: "gtin12", 13: "gtin13", 14: "gtin14"}.get(len(gtin))


def availability_of(flags: list[bool | None]) -> str | None:
    """schema.org availability from stock flags, or None when we do not know.

    Only an explicit flag counts. Roughly a third of observations carry no
    stock signal at all, and defaulting those to InStock would assert a fact
    nobody observed; omitting the property is what the vocabulary is for.
    """
    known = [f for f in flags if f is not None]
    if not known:
        return None
    if any(known):
        return "https://schema.org/InStock"
    return "https://schema.org/OutOfStock"


def offer_jsonld(price: PriceOut) -> dict:
    offer: dict = {
        "@type": "Offer",
        "price": f"{price.price:.2f}",
        "priceCurrency": price.currency,
        "seller": {"@type": "Organization", "name": price.retailer_name},
        "availableAtOrFrom": {"@type": "Place", "name": _place(price)},
    }
    if (availability := availability_of([price.in_stock])) is not None:
        offer["availability"] = availability
    if price.url:
        offer["url"] = price.url
    return offer


def head_for_detail(detail: ProductDetail, medal_assets: Collection[str] = ()) -> Head:
    shop_count = detail.location_count
    low, high = detail.cheapest_usd, detail.dearest_usd
    awards = [
        f"{a.medal}, {a.competition} {a.year}" if a.medal else f"{a.competition} {a.year}"
        for a in detail.awards
    ]
    path = product_path(detail.id, detail.name)

    title = (
        f"{detail.name}: duty-free prices at {shop_count} shops | {SITE_NAME}"
        if shop_count > 1
        else f"{detail.name}: duty-free price | {SITE_NAME}"
    )
    bits = [f"Compare {detail.name} across airport duty-free shops."]
    if low is not None and shop_count > 1:
        bits.append(f"From ${low:,.2f} to ${high:,.2f} at {shop_count} shops,")
        bits.append("every price dated.")
    elif low is not None:
        bits.append(f"Currently ${low:,.2f}, price dated.")
    description = _truncate(" ".join(bits))

    schema: dict = {
        "@context": "https://schema.org",
        "@type": "Product",
        "@id": f"{path}#product",
        "name": detail.name,
        "url": path,
        # Same factual sentence as the meta description: prices and coverage,
        # never marketing copy (we store facts, not expression).
        "description": description,
    }
    if detail.brand:
        schema["brand"] = {"@type": "Brand", "name": detail.brand}
    if gtin_key := gtin_property(detail.gtin):
        schema[gtin_key] = detail.gtin
    if detail.image_url:
        schema["image"] = detail.image_url
    if detail.category:
        schema["category"] = detail.category
    if awards:
        schema["award"] = awards
    if detail.prices:
        # The price of record (structure review, 9 Sep): the shop's own price
        # in its own currency is the price on the page and the only price in
        # the markup. Each Offer carries the observation as quoted. The
        # combined offer states a range only when every shop quoted one
        # currency, from those quotes; with mixed currencies it carries none,
        # because a USD range would be our conversion, which the page shows
        # second and labelled. Before this the range was USD from our rates
        # while the offers beneath it were in pounds and dirhams, which is the
        # incongruence Mark warned tanks rankings.
        priced = detail.prices
        aggregate: dict = {
            "@type": "AggregateOffer",
            "offerCount": shop_count,
            "url": path,
            "offers": [offer_jsonld(p) for p in priced],
        }
        currencies = {p.currency for p in priced}
        if len(currencies) == 1:
            quotes = [p.price for p in priced]
            aggregate["priceCurrency"] = priced[0].currency
            aggregate["lowPrice"] = f"{min(quotes):.2f}"
            aggregate["highPrice"] = f"{max(quotes):.2f}"
        if (availability := availability_of([p.in_stock for p in priced])) is not None:
            aggregate["availability"] = availability
        schema["offers"] = aggregate
    breadcrumbs = {
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [
            {"@type": "ListItem", "position": 1, "name": "Products", "item": "/products"},
            *([{"@type": "ListItem", "position": 2, "name": detail.category,
                "item": category_path(detail.category)}] if detail.category else []),
            {"@type": "ListItem",
             "position": 3 if detail.category else 2,
             "name": detail.name},
        ],
    }
    return Head(
        title=title,
        description=description,
        canonical_path=path,
        jsonld=[schema, breadcrumbs],
        image=detail.image_url,
        preload_image=detail.image_url,
        body=product_body(detail, medal_assets),
        seed=detail.model_dump(mode="json"),
        last_modified=max((p.observed_at for p in detail.prices), default=None),
    )


# --- the rendered body ------------------------------------------------------
#
# Every function below mirrors one piece of the React app. The pairings are
# named so a change on one side can be carried to the other:
#   fmt_*            <- web/src/lib/format.ts
#   bottle_mark      <- components/BottleMark.tsx
#   medal_html       <- components/Medal.tsx
#   header_html      <- components/SiteHeader.tsx + AnnouncementBar.tsx (default state)
#   product_body     <- pages/ProductPage.tsx + components/PriceTable.tsx (no airports chosen)
# The markup uses the SPA's class names so the stylesheet already loaded in the
# head styles it, and React's mount replaces it with identical geometry.

#: Mirrors Medal.tsx MEDAL_ART_VERSION. Bump both when medal artwork changes.
MEDAL_ART_VERSION = 2

# Intl.NumberFormat("en-US", {style: "currency"}) prefixes, for the currencies the
# collectors have seen or are about to. Anything else renders as "CODE 1,234.50",
# which is also what Intl does for a code it has no symbol for.
_CURRENCY_PREFIX = {
    "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "KRW": "₩", "INR": "₹", "ILS": "₪",
    "VND": "₫", "PHP": "₱", "CAD": "CA$", "AUD": "A$", "HKD": "HK$", "MXN": "MX$",
    "NZD": "NZ$", "TWD": "NT$", "CNY": "CN¥", "BRL": "R$", "XAF": "FCFA",
}
# Currencies Intl formats with no minimum fraction digits.
_ZERO_DECIMAL = {"JPY", "KRW", "ISK", "VND", "CLP", "XAF", "XOF", "UGX", "PYG", "RWF"}


def fmt_usd(value: float | None) -> str:
    """format.ts formatUsd."""
    return "-" if value is None else f"${value:,.2f}"


def fmt_local(value: float, currency: str) -> str:
    """format.ts formatLocal (en-US Intl currency output)."""
    prefix = _CURRENCY_PREFIX.get(currency, f"{currency} ")
    number = f"{value:,.2f}"
    if currency in _ZERO_DECIMAL:
        number = number.rstrip("0").rstrip(".")
    return f"{prefix}{number}"


def fmt_size(size_ml: int | None) -> str | None:
    """format.ts formatSize."""
    if not size_ml:
        return None
    if size_ml >= 1000:
        litres = size_ml / 1000
        return f"{litres:.0f}L" if size_ml % 1000 == 0 else f"{litres:.1f}L"
    return f"{size_ml}ml"


def fmt_observed(seen: datetime, now: datetime) -> str:
    """format.ts formatObservedAt. Relative, like the SPA; the <time> element
    around it carries the absolute date for machines."""
    hours = int((now - seen).total_seconds() // 3600)
    if hours < 1:
        return "just now"
    if hours < 24:
        return f"{hours}h ago"
    days = hours // 24
    return "yesterday" if days == 1 else f"{days} days ago"


def is_stale(seen: datetime, now: datetime, threshold_days: int = 3) -> bool:
    """format.ts isStale."""
    return (now - seen).total_seconds() > threshold_days * 86400


_MARK_TINTS = [
    ("#EDE4D3", "#6B5426"), ("#E2E7E0", "#3B5340"), ("#E4E3EC", "#3F4468"),
    ("#EFE2E0", "#6B3F3B"), ("#E3EAEE", "#345165"), ("#EBE7DE", "#5A5140"),
]


def _js_hash(value: str) -> int:
    # BottleMark.tsx hashOf: (hash * 31 + charCode) | 0 over UTF-16 code units,
    # then Math.abs. Reproduced bit for bit so both sides pick the same tint.
    h = 0
    units = value.encode("utf-16-le")
    for i in range(0, len(units), 2):
        h = (h * 31 + int.from_bytes(units[i:i + 2], "little")) & 0xFFFFFFFF
    if h >= 0x80000000:
        h -= 0x100000000
    return abs(h)


def _initials(brand: str | None, name: str) -> str:
    source = re.sub(r"[^A-Za-z0-9 ]", " ", brand or name or "").strip()
    words = source.split()
    if not words:
        return "DF"
    if len(words) == 1:
        return words[0][:2].upper()
    return (words[0][0] + words[1][0]).upper()


def bottle_mark(name: str, brand: str | None, large: bool = False) -> str:
    """BottleMark.tsx: the monogram shown when a product has no photograph."""
    tint, ink = _MARK_TINTS[_js_hash(f"{brand or ''}{name}") % len(_MARK_TINTS)]
    cls = "bottle-mark bottle-mark--lg" if large else "bottle-mark"
    return (
        f'<div class="{cls}" style="--mark-tint: {tint}; --mark-ink: {ink};" aria-hidden="true">'
        f'<span class="bottle-mark__initials">{_e(_initials(brand, name))}</span>'
        '<span class="bottle-mark__rule"></span></div>'
    )


def medal_html(award: AwardOut, medal_assets: Collection[str]) -> str:
    """Medal.tsx. The SPA falls back to the text badge when the artwork 404s;
    the server knows in advance from the medal directory listing."""
    asset = None
    if award.competition_slug and award.medal:
        tier = re.sub(r"\s+", "-", award.medal.lower())
        filename = f"{award.competition_slug}-{tier}.png"
        if filename in medal_assets:
            asset = f"/medals/{filename}?v={MEDAL_ART_VERSION}"
    if asset is None:
        label = " - ".join(str(x) for x in (award.medal, award.competition, award.year) if x)
        return f'<span class="badge badge--medal">{_e(label)}</span>'
    year = f" {award.year}" if award.year else ""
    return (
        '<div class="medal">'
        f'<img class="medal__image" src="{_a(asset)}" alt="{_a(f"{award.medal} medal, {award.competition}")}" loading="lazy" />'
        f'<span class="medal__text"><span class="medal__tier">{_e(award.medal)}</span>'
        f'<span class="medal__competition">{_e(award.competition)}{_e(year)}</span></span>'
        "</div>"
    )


#: MegaMenu.tsx DEPARTMENTS, verbatim: the nav's lead items, each a link to its
#: own page and the toggle for its panel (Adam's prototype led with departments).
#: The family keys and words are taxonomy's (FAMILY_LABEL); a test pins the two.
_DEPARTMENTS = [
    ("airports", "/airports", "Airports"),
    ("liquor", "/products?family=liquor", "Drinks"),
    ("beauty", "/products?family=beauty", "Beauty"),
    ("articles", "/articles", "Articles"),
]
# SiteHeader.tsx NAV and AnnouncementBar.tsx ITEMS, verbatim. Reviews, buying
# guides and news are in the Articles department's panel, not the row.
_NAV = [
    ("/feature/price-tracker", "Price tracker"),
    ("/exclusives", "Exclusives"),
    ("/awards", "Awards"),
]
_ANNOUNCE = [
    ("/feature/alerts", "Free price alerts"),
    ("/awards", "New: Duty Free Awards open for entries"),
    ("/feature/newsletter", "Subscribe to the weekly"),
]


#: The department panel's id (MegaMenu.tsx MEGA_MENU_ID); every toggle's
#: aria-controls points at it.
_MEGA_MENU_ID = "site-mega-menu"
#: AccountMenu.tsx signed out: the profile circle that leads to sign-in, the same
#: size as the signed-in initials, so the row does not move when the account
#: arrives (LineIcon.tsx "person").
_ACCOUNT_GUEST = (
    '<div class="account-menu"><a href="/login" class="account-menu__trigger account-menu__trigger--guest" '
    'aria-label="Sign in" title="Sign in"><svg class="line-icon" viewBox="0 0 24 24" aria-hidden="true" '
    'focusable="false"><path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M4.5 20.5a7.5 7.5 0 0 1 15 0"></path>'
    "</svg></a></div>"
)


#: SocialLinks.tsx's inline glyphs, as the DOM renders them (no icon font, no request).
_SOCIAL_GLYPHS = {
    "instagram": (
        '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">'
        '<rect x="3" y="3" width="18" height="18" rx="5" fill="none" stroke="currentColor" stroke-width="1.8"></rect>'
        '<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.8"></circle>'
        '<circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"></circle></svg>'
    ),
    "youtube": (
        '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">'
        '<rect x="2.5" y="5.5" width="19" height="13" rx="4" fill="none" stroke="currentColor" stroke-width="1.8"></rect>'
        '<path d="M10 9.2v5.6l4.8-2.8z" fill="currentColor"></path></svg>'
    ),
}
_SOCIAL_LABELS = {"instagram": "Instagram", "youtube": "YouTube"}


def social_links_html(profiles: dict[str, str], variant: str = "nav") -> str:
    """SocialLinks.tsx: nothing at all until a profile URL exists (lib/social.ts
    reads the same `window.__DFP_SOCIAL__` the shell carries), in its fixed order."""
    links = [
        f'<a href="{_a(url)}" class="social__link" target="_blank" rel="noopener" '
        f'aria-label="Duty Free Professor on {label}" title="{label}">{_SOCIAL_GLYPHS[network]}'
        + (f'<span class="social__label">{label}</span>' if variant == "footer" else "")
        + "</a>"
        for network, label in _SOCIAL_LABELS.items()
        if (url := profiles.get(network))
    ]
    if not links:
        return ""
    return f'<div class="social social--{_a(variant)}">' + "".join(links) + "</div>"


def header_html(
    flags: dict[str, bool], active_path: str | None = None, social: dict[str, str] | None = None
) -> str:
    """The header in its default state (menu closed, department panel closed, no
    airports chosen), so the hero below it sits at the same height before and
    after React mounts. The search form works without JavaScript: /products
    reads ?q= itself. The department panel is the empty, hidden shell the SPA
    also renders while closed (its links arrive only when it opens): the same
    DOM, so nothing here is shown to a crawler that a visitor does not get.
    `active_path` marks the item SiteHeader.tsx marks on that page: an airport
    page's Airports, an article's Articles, a collection's Exclusives or Awards; a
    product page, none. The profile circle is drawn signed out: the SPA swaps in
    the initials at the same size."""
    parts = ['<div class="announce"><div class="announce__inner">']
    for i, (to, label) in enumerate(_ANNOUNCE):
        dot = '<span class="announce__dot" aria-hidden="true">·</span>' if i else ""
        parts.append(
            f'<span class="announce__item">{dot}<a class="announce__link" href="{_a(to)}">{_e(label)}</a></span>'
        )
    parts.append("</div></div>")
    parts.append(
        '<header class="site-header"><div class="site-header__inner">'
        '<a href="/" class="site-header__brand"><img src="/logo-duty-free-professor.png" '
        'alt="Duty Free Professor" class="site-header__logo" width="1500" height="253" /></a>'
        '<form class="site-header__search site-search" role="search" action="/products" method="get">'
        '<input type="search" name="q" placeholder="Search product, brand, or airport" '
        'aria-label="Search products, brands and airports" role="combobox" aria-expanded="false" '
        'aria-controls="site-search-results" aria-autocomplete="list" autocomplete="off" />'
        '<div id="site-search-results" class="site-search__panel" role="listbox" '
        'aria-label="Search suggestions" hidden></div>'
        "</form>"
        '<div class="site-header__actions">'
    )
    if flags.get("myAirports", True):
        parts.append(
            '<div class="my-airports">'
            '<button type="button" class="my-airports__btn" aria-expanded="false">'
            '<span class="my-airports__icon" aria-hidden="true">✈</span>'
            '<span class="my-airports__label">Set your airports</span></button></div>'
        )
    parts.append(_ACCOUNT_GUEST + "</div>")
    parts.append(
        '<button type="button" class="site-header__burger" aria-expanded="false" aria-label="Menu">☰</button>'
        '</div><nav class="site-nav"><div class="site-nav__inner">'
    )
    for _key, to, label in _DEPARTMENTS:
        # Each department group holds its link and its panel's toggle (an empty
        # button; the chevron is CSS), exactly as SiteHeader.tsx renders it closed.
        cls = "site-nav__link site-nav__link--active" if to == active_path else "site-nav__link"
        parts.append(
            f'<div class="site-nav__group"><a class="{cls}" href="{_a(to)}">{_e(label)}</a>'
            f'<button type="button" class="site-nav__more" aria-expanded="false" '
            f'aria-controls="{_MEGA_MENU_ID}" aria-label="{_a(f"Show the {label} menu")}"></button></div>'
        )
    parts.append('<span class="site-nav__sep" aria-hidden="true"></span>')
    for to, label in _NAV:
        if to.startswith("/feature/") and not flags.get("teasers", True):
            continue
        cls = "site-nav__link site-nav__link--active" if to == active_path else "site-nav__link"
        parts.append(f'<a class="{cls}" href="{_a(to)}">{_e(label)}</a>')
    parts.append(
        '<span class="site-nav__spacer"></span>'
        + social_links_html(settings.social_profiles if social is None else social)
        + ('<a class="site-nav__cta" href="/savings">My savings</a>' if flags.get("myAirports", True) else "")
        + "</div>"
        f'<div id="{_MEGA_MENU_ID}" class="mega" hidden role="region" '
        'aria-label="Browse by department"></div>'
        "</nav></header>"
    )
    return "".join(parts)


def _e(text: object) -> str:
    return html.escape(str(text), quote=False)


def _a(text: object) -> str:
    return html.escape(str(text), quote=True)


def _place(price: PriceOut) -> str:
    return f"{price.location_name} ({price.location_iata})" if price.location_iata else price.location_name


def price_table_html(prices: list[PriceOut], now: datetime, show_data_notes: bool = True) -> str:
    """PriceTable.tsx with no airports chosen (no "your trip" marks)."""
    if not prices:
        return '<p class="muted">No prices collected for this product yet.</p>'
    # The "Cheapest" badge marks where to BUY, so out-of-stock rows only count
    # when nothing in stock exists anywhere.
    buyable = [p for p in prices if p.in_stock is not False]
    pool = buyable or prices
    usd = [p.price_usd for p in pool if p.price_usd is not None]
    cheapest = min(usd) if usd else None

    rows = []
    for price in prices:
        is_best = cheapest is not None and price.price_usd == cheapest and price.in_stock is not False
        catalogue = " - online catalogue" if price.is_catalogue_only else ""
        was = (
            f'<div class="price-table__was">was {_e(fmt_local(price.was_price, price.currency))}</div>'
            if price.was_price else ""
        )
        best_badge = '<span class="badge badge--solid-good">Cheapest</span>' if is_best else ""
        stock = "Unknown" if price.in_stock is None else ("In stock" if price.in_stock else "Out of stock")
        tone = "warn" if is_stale(price.observed_at, now) else "good"
        link = (
            f'<div><a class="price-table__link" href="{_a(price.url)}" target="_blank" rel="noreferrer nofollow">View at shop</a></div>'
            if price.url else ""
        )
        # An airport row links to the airport's own page; an online catalogue has none.
        place = (
            f'<a href="{_a(price.location_path)}">{_e(_place(price))}</a>'
            if price.location_path else _e(_place(price))
        )
        rows.append(
            f'<tr{" class=\"price-table__row--best\"" if is_best else ""}>'
            f'<td><div class="price-table__place">{place}</div>'
            f'<div class="price-table__retailer">{_e(price.retailer_name)}{_e(catalogue)}</div></td>'
            f'<td class="price-table__shop">{_e(fmt_local(price.price, price.currency))}{was}</td>'
            f'<td><div class="price-table__usd-group"><span class="price-table__usd">{_e(fmt_usd(price.price_usd))}</span>{best_badge}</div></td>'
            f'<td class="price-table__local">{stock}</td>'
            f'<td><span class="badge badge--{tone}"><time datetime="{_a(price.observed_at.isoformat())}">{_e(fmt_observed(price.observed_at, now))}</time></span>{link}</td>'
            "</tr>"
        )
    table = (
        '<div class="scroll-x"><table class="price-table"><thead><tr>'
        "<th>Where</th><th>Shop price</th>"
        '<th>Our conversion<span class="price-table__th-note">to US dollars, at the exchange rate we held on the date checked</span></th>'
        "<th>Availability</th><th>Checked</th>"
        f'</tr></thead><tbody>{"".join(rows)}</tbody></table></div>'
    )
    if show_data_notes:
        table += (
            '<p class="price-note">These are public list prices we observed on the dates shown. '
            "The shop's own price in its own currency is the price; the US dollar figure is our "
            "conversion, at the exchange rate we held on the date checked, so shops can be compared. "
            "Duty-free pricing can vary by your destination, by loyalty membership, and increasingly "
            "by traveller, so treat these as a guide rather than a quote. Every price links back to "
            "the shop that set it.</p>"
        )
    return table


def product_body(
    detail: ProductDetail,
    medal_assets: Collection[str] = (),
    flags: dict[str, bool] | None = None,
    now: datetime | None = None,
) -> str:
    """ProductPage.tsx in its default state, as static HTML for the root element.

    Omitted on purpose: the "On your trip" block (needs the shopper's saved
    airports, which live in their browser), the related-products rail (a second
    request the SPA makes after mount) and the footer. Everything a shopper
    sees above the fold on first paint is here.
    """
    now = now or datetime.now(UTC)
    flags = flags if flags is not None else {}
    # A country code like "AE" means nothing to a shopper, so show only real names.
    country = detail.country_of_origin if detail.country_of_origin and len(detail.country_of_origin) > 3 else None
    meta_rest = [
        fmt_size(detail.size_ml),
        f"{detail.abv:g}% ABV" if detail.abv else None,
        country,
        f"Barcode {detail.gtin}" if detail.gtin else None,
    ]
    meta_bits = []
    if detail.brand:
        meta_bits.append(f'<a href="{_a(brand_path(detail.brand, detail.brand_slug))}">{_e(detail.brand)}</a>')
    meta_bits.extend(_e(b) for b in meta_rest if b)
    meta = " · ".join(meta_bits)

    photo = (
        f'<img src="{_a(detail.image_url)}" alt="{_a(detail.name)}" fetchpriority="high" />'
        if detail.image_url else bottle_mark(detail.name, detail.brand, large=True)
    )
    credit = (
        f'<span class="product-photo__credit">Photo: {_e(detail.image_source)}</span>'
        if detail.image_source else ""
    )
    category = (
        f'<a class="product-category" href="{_a(category_path(detail.category))}">{_e(detail.category)}</a>'
        if detail.category else ""
    )
    exclusive = (
        '<div class="medal-list"><span class="badge badge--excl">Travel-retail exclusive</span></div>'
        if detail.is_exclusive else ""
    )
    awards = (
        '<div class="medal-list">' + "".join(medal_html(a, medal_assets) for a in detail.awards) + "</div>"
        if detail.awards else ""
    )
    verdict = ""
    if detail.prices:
        best = detail.prices[0]
        spread = (
            detail.dearest_usd - detail.cheapest_usd
            if detail.cheapest_usd is not None and detail.dearest_usd is not None else None
        )
        save = (
            f'<span class="product-verdict__save">save {_e(fmt_usd(spread))} vs the dearest</span>'
            if spread is not None and spread > 0.5 else ""
        )
        verdict = (
            '<div class="product-verdict">'
            f'<span class="product-verdict__price">{_e(fmt_usd(best.price_usd))}</span>'
            f'<span>cheapest at <span class="product-verdict__where">{_e(_place(best))}</span></span>'
            f"{save}</div>"
        )

    return (
        header_html(flags)
        + "<main>"
        '<section class="product-shell"><div class="product-shell__inner">'
        '<a href="/products" class="product-back">← Back to products</a>'
        '<div class="product-hero">'
        f'<div><div class="product-photo">{photo}</div>{credit}</div>'
        f"<div>{category}"
        f'<h1 class="product-title">{_e(detail.name)}</h1>'
        f'{f"<p class=\"product-meta\">{meta}</p>" if meta else ""}'
        f"{exclusive}{awards}{verdict}"
        "</div></div></div></section>"
        '<div class="product-body stack"><section class="panel">'
        '<div class="product-section-head"><h2 class="section-heading">Price by shop</h2>'
        '<a href="/savings" class="btn btn--ghost">Set my airports</a></div>'
        f"{price_table_html(detail.prices, now)}"
        "</section></div></main>"
    )


# --- airport pages ------------------------------------------------------------
#
#   product_card_html  <- components/ProductCard.tsx (no airports chosen)
#   airport_body       <- pages/AirportPage.tsx (default state)
# Same rule as the product page: the served markup is what the SPA draws with no
# airports chosen, class for class, so a no-JavaScript reader gets the page and
# React's mount changes nothing a shopper can see.


def fmt_date(value: datetime | None) -> str:
    """format.ts formatDate: an absolute date, in UTC like the SPA's."""
    if value is None:
        return "not yet"
    if value.tzinfo is not None:
        value = value.astimezone(UTC)
    return value.strftime("%-d %b %Y")


def _js_round(value: float) -> int:
    # Math.round rounds halves up; Python's round() rounds them to even.
    return int(value + 0.5)


def product_card_html(product: ProductSummary, medal_assets: Collection[str], flags: dict[str, bool] | None = None) -> str:
    """ProductCard.tsx with no airports chosen: corner medal or tag, photo or
    monogram, category, name, the stocked line, the per-shop bars, the footer."""
    flags = flags if flags is not None else {}
    medal = product.top_award
    medal_asset = None
    if medal and medal.competition_slug and medal.medal:
        filename = f"{medal.competition_slug}-{re.sub(r'\s+', '-', medal.medal.lower())}.png"
        if filename in medal_assets:
            medal_asset = f"/medals/{filename}?v={MEDAL_ART_VERSION}"
    tag = None
    if product.award_count > 0 and medal_asset is None:
        tag = ("Award winner", "medal")
    elif not product.award_count and product.is_exclusive:
        tag = ("Exclusive", "excl")

    parts = [f'<a href="{_a(product_path(product.id, product.name))}" class="product-card">']
    if medal_asset:
        year = f" {medal.year}" if medal.year else ""
        alt = f"{medal.medal or 'Medal'}, {medal.competition}{year}"
        parts.append(
            f'<img class="product-card__medal" src="{_a(medal_asset)}" alt="{_a(alt)}" loading="lazy" />'
        )
    parts.append('<div class="product-card__photo">')
    if tag:
        parts.append(f'<span class="product-card__tag product-card__tag--{tag[1]}">{_e(tag[0])}</span>')
    parts.append(
        f'<img src="{_a(product.thumb_url)}" alt="" loading="lazy" />' if product.thumb_url
        else bottle_mark(product.name, product.brand)
    )
    parts.append("</div>")
    if product.category:
        parts.append(f'<span class="product-card__category">{_e(product.category)}</span>')
    parts.append(f'<span class="product-card__name">{_e(product.name)}</span>')
    lead = (
        "Travel-retail exclusive" if product.is_exclusive
        else f"In {product.location_count} shops" if product.location_count > 1
        else "One shop"
    )
    meta = " · ".join(
        b for b in (lead, fmt_size(product.size_ml), f"{product.abv:g}%" if product.abv else None) if b
    )
    parts.append(f'<span class="product-card__meta">{_e(meta)}</span>')

    shops = product.top_prices or []
    if flags.get("richCards", True) and len(shops) > 1:
        max_usd = max(shop.usd for shop in shops)
        parts.append('<div class="mini-compare" aria-label="Price by shop">')
        for index, shop in enumerate(shops):
            cls = "mini-compare__row mini-compare__row--best" if index == 0 else "mini-compare__row"
            bar = max(16, _js_round(shop.usd / max_usd * 100)) if max_usd else 16
            parts.append(
                f'<div class="{cls}" style="--bar: {bar}%;">'
                f'<span class="mini-compare__label">{_e(shop.label)}</span>'
                '<span class="mini-compare__track"><span class="mini-compare__fill"></span></span>'
                f'<span class="mini-compare__price">{_e(fmt_usd(shop.usd))}</span></div>'
            )
        parts.append("</div>")

    spread = (
        product.dearest_usd - product.cheapest_usd
        if product.location_count > 1 and product.cheapest_usd is not None and product.dearest_usd is not None
        else None
    )
    where = product.best_location_iata or product.best_location
    parts.append('<div class="product-card__footer">')
    if len(shops) > 1 and spread is not None and spread > 0.5:
        parts.append(
            f'<span class="product-card__save">Save {_e(fmt_usd(spread))}</span>'
            f'<span class="product-card__buyat">buy at {_e(where)}</span>'
        )
    else:
        label = f'<span class="product-card__price-label">at {_e(where)}</span>' if where else ""
        parts.append(
            f'<span class="product-card__price">{_e(fmt_usd(product.cheapest_usd))}{label}</span>'
            '<span class="product-card__compare">Compare →</span>'
        )
    parts.append("</div></a>")
    return "".join(parts)


def airport_lede(detail: AirportDetail) -> str:
    """The one sentence under the H1, also the meta description: counts and the
    checked date, all from the database."""
    shops = " and ".join(s.retailer_name for s in detail.shops) or "the airport's shops"
    bits = [
        f"{detail.products:,} duty-free products priced by {shops} at {detail.name} ({detail.iata}), "
        f"checked {fmt_date(detail.last_collected_at)}."
    ]
    if detail.comparable:
        where = detail.city or detail.name
        bits.append(
            f"{detail.comparable:,} of them are sold at other airports we track, so you can see "
            f"where {where} is the better buy."
        )
    return " ".join(bits)


def airport_description(detail: AirportDetail) -> str:
    """The meta description: the lede's facts in fewer words, so a long airport
    name still fits the snippet."""
    bits = [
        f"{detail.products:,} duty-free products at {detail.name} ({detail.iata}), "
        f"checked {fmt_date(detail.last_collected_at)}."
    ]
    if detail.comparable:
        bits.append(
            f"{detail.comparable:,} sold at other airports too: see where "
            f"{detail.city or detail.name} is the better buy."
        )
    return _truncate(" ".join(bits))


def airport_facts_html(detail: AirportDetail) -> str:
    """AirportGuide.tsx `AirportFacts`: the summary, the standing facts and the
    four counts, in one compact panel. A fact nobody has written is absent."""
    guide = detail.guide
    shops = ", ".join(f"{s.retailer_name} ({s.products:,} products)" for s in detail.shops)
    facts: list[tuple[str, str]] = []
    if guide and guide.operator:
        facts.append(("Duty free operator", _e(guide.operator)))
    if guide and guide.terminals:
        facts.append(("Terminals", str(len(guide.terminals))))
    if guide and guide.hours:
        # The date beside the hours, as AirportHours.tsx prints it: a date, never "verified",
        # never a rate (decision 10's wording ceiling).
        when = guide.hours_provenance
        stamp = (
            f' <span class="airport-fact__when muted">{"entered" if when.kind == "hand" else "collected"} {fmt_date(when.observed_at)}</span>'
            if when else ""
        )
        facts.append(("Opening hours", _e(guide.hours) + stamp))
    if guide and guide.map_url:
        label = _e(guide.map_label or "The airport\u2019s own map")
        facts.append((
            "Terminal map",
            f'<a href="{_a(guide.map_url)}" class="airport-facts__map" rel="nofollow noopener" '
            f'target="_blank">{label}</a>',
        ))
    if shops:
        facts.append(("Shops we read", _e(shops)))
    facts.append(("Prices checked", _e(fmt_date(detail.last_collected_at))))
    rows = "".join(
        f'<div class="airport-fact"><dt>{_e(label)}</dt><dd>{value}</dd></div>' for label, value in facts
    )
    summary = (
        f'<p class="airport-facts__summary">{_e(guide.overview)}</p>' if guide and guide.overview else ""
    )
    return (
        '<section class="airport-facts"><div class="airport-facts__main">'
        + summary
        + f'<dl class="airport-facts__list">{rows}</dl></div>'
        '<dl class="airport-facts__counts">'
        f'<div class="airport-stat"><dt>Products priced</dt><dd>{detail.products:,}</dd></div>'
        f'<div class="airport-stat"><dt>Sold elsewhere too</dt><dd>{detail.comparable:,}</dd></div>'
        f'<div class="airport-stat"><dt>Cheapest here</dt><dd>{detail.cheapest_here:,}</dd></div>'
        f'<div class="airport-stat"><dt>Travel exclusives</dt><dd>{detail.exclusives:,}</dd></div>'
        "</dl></section>"
    )


def airport_terminals_html(detail: AirportDetail) -> str:
    """AirportGuide.tsx `AirportTerminals`: a row per terminal that opens in
    place, then the services and the date a person checked the locations."""
    guide = detail.guide
    if guide is None or not (guide.terminals or guide.specialty or guide.services):
        return ""
    out = [
        '<section class="airport-terminals"><div class="product-section-head">',
        f'<h2 class="section-heading">Where to shop at {_e(detail.name)}</h2>',
        f'<span class="muted">{_e(guide.access)}</span>' if guide.access else "",
        "</div>",
    ]
    for t in guide.terminals:
        out.append(
            '<details class="terminal"><summary class="terminal__head">'
            f'<span class="terminal__name">{_e(t.name)}</span>'
            + (f'<span class="terminal__airlines">{_e(t.airlines)}</span>' if t.airlines else "")
            + f'<span class="terminal__teaser">{_e(t.duty_free)}</span></summary>'
            f'<div class="terminal__body"><p>{_e(t.duty_free)}</p>'
            + (
                '<p class="terminal__specialty"><span class="terminal__label">Specialty shops</span>'
                f'{_e(t.specialty)}</p>' if t.specialty else ""
            )
            + "</div></details>"
        )
    if guide.specialty:
        out.append(
            '<p class="terminal__specialty"><span class="terminal__label">Specialty shops</span>'
            f'{_e(guide.specialty)}</p>'
        )
    for service in guide.services:
        out.append(
            f'<p class="airport-terminals__service"><strong>{_e(service.title)}</strong> {_e(service.body)}</p>'
        )
    if guide.checked:
        out.append(
            f'<p class="airport-terminals__checked muted">Shop locations checked {_e(guide.checked)}. '
            "Retailers move between gates, so check your terminal\u2019s own map on the day.</p>"
        )
    out.append("</section>")
    return "".join(out)


def airport_featured_html(detail: AirportDetail, medal_assets: Collection[str], flags: dict[str, bool]) -> str:
    """AirportFeatured.tsx: "Featured at <airport>", one row of cards per family that has a
    comparison here. A family with nothing comparable draws no row; no families, no section."""
    if not detail.featured:
        return ""
    out = [
        '<section class="airport-featured"><div class="product-section-head">',
        f'<h2 class="section-heading">Featured at {_e(detail.name)}</h2>',
        f'<span class="muted">The best value here by family: bottles {_e(detail.iata)} is the cheapest of our airports for.</span>',
        "</div>",
    ]
    for family in detail.featured:
        if not family.items:
            continue
        out.append(
            f'<div class="featured-family" data-family="{_a(family.key)}">'
            f'<h3 class="featured-family__title">{_e(family.label)}</h3>'
            '<div class="product-grid product-grid--row">'
            + "".join(product_card_html(p, medal_assets, flags) for p in family.items)
            + "</div></div>"
        )
    out.append("</section>")
    return "".join(out)


def airport_shelf(detail: AirportDetail, tab: str) -> tuple[str, int, str]:
    """A shelf's label, its count and the line under the tabs. AirportPage.tsx
    `airportShelf` says the same words."""
    if tab == "exclusives":
        return (
            "Travel exclusives",
            detail.exclusives,
            f"Bottlings and editions sold only in travel retail, as we find them at {detail.name}.",
        )
    if tab == "all":
        line = (
            f"{detail.category} priced at {detail.name}, cheapest first where we can compare."
            if detail.category
            else f"Everything we price at {detail.name}, shop by shop."
        )
        return ("All products", detail.products, line)
    return (
        "Best value here",
        detail.cheapest_here,
        f"{detail.iata} is the cheapest of our airports for {detail.cheapest_here:,} of the "
        f"{detail.comparable:,} products sold at more than one. The biggest gaps first.",
    )


def airport_body(
    detail: AirportDetail,
    medal_assets: Collection[str] = (),
    flags: dict[str, bool] | None = None,
    tab: str = "value",
) -> str:
    """AirportPage.tsx for the URL asked for, as static HTML for the root element:
    the hero, the facts panel, the terminal rows, the write-up, and the one shelf
    of products the URL selects.

    Omitted on purpose: the footer, the "on your trip" state of the add button
    (the shopper's airports live in their browser), and the demo's placeholder
    view, which is ours and never a reader's.
    """
    flags = flags if flags is not None else {}
    path = detail.path
    where = ", ".join(b for b in (detail.city, detail.country) if b)
    eyebrow = f"{detail.iata} · {where}" if where else detail.iata
    # The two actions ride on the shopper's airports feature: the add button, and the
    # comparison tool opened on this airport (AirportPage.tsx draws the same pair).
    add = (
        '<div class="airport-hero__actions"><button type="button" class="btn btn--light airport-hero__add" '
        'aria-pressed="false">Add to my airports</button>'
        f'<a href="{_a(savings_from_path(detail.iata))}" class="btn btn--light airport-hero__compare">Compare from here</a></div>'
        if flags.get("myAirports", True) else ""
    )

    # A shelf with nothing on it is not offered, exactly as in the SPA.
    shelves = [
        *(["value"] if detail.savings else []),
        *(["exclusives"] if detail.exclusive_items else []),
        "all",
    ]
    active = tab if tab in shelves else "all"
    label, _count, line = airport_shelf(detail, active)
    items = (
        detail.savings if active == "value"
        else detail.exclusive_items if active == "exclusives"
        else detail.items
    )

    out = [
        header_html(flags, active_path="/airports"),
        "<main>",
        '<section class="airports-hero"><div class="airports-hero__inner">',
        '<nav class="crumbs" aria-label="Breadcrumb"><a href="/airports">Airports we price</a>'
        f'<span class="crumbs__sep" aria-hidden="true">›</span><span>{_e(detail.name)}</span></nav>',
        f'<span class="eyebrow">{_e(eyebrow)}</span>',
        f'<h1 class="airports-hero__title">Duty free at {_e(detail.name)}</h1>',
        f'<p class="airports-hero__lede">{_e(airport_lede(detail))}</p>',
        add,
        "</div></section>",
        '<div class="page stack">',
        airport_facts_html(detail),
        airport_terminals_html(detail),
    ]
    if detail.writeup:
        out.append(editorial_block_html(detail.writeup))
    out.append(airport_featured_html(detail, medal_assets, flags))
    out.append(category_links_html(f"Categories at {detail.name}", detail.category_pages))

    tabs = "".join(
        f'<a href="{_a(path + airport_query(detail.category if key == "all" else None, detail.sort if key == "all" else "featured", 1, key, _features(detail) if key == "all" else None))}#products" '
        f'class="shelf-tab{" shelf-tab--active" if key == active else ""}"'
        + (' aria-current="page"' if key == active else "")
        + f'>{_e(airport_shelf(detail, key)[0])}'
        f'<span class="shelf-tab__count">{airport_shelf(detail, key)[1]:,}</span></a>'
        for key in shelves
    )
    out.append(
        '<section id="products" class="airport-shelf">'
        f'<nav class="shelf-tabs" aria-label="Products at this airport">{tabs}</nav>'
        f'<div class="shelf-head"><p class="shelf-head__line muted">{_e(line)}</p>'
        + ('<a href="/exclusives" class="btn btn--ghost">All exclusives</a>' if active == "exclusives" else "")
        + "</div>"
    )
    if active == "all":
        out.append(_airport_chips(path, detail))
        out.append(_airport_feature_chips(path, detail))
        out.append(_airport_sort_bar(detail))
    if items:
        out.append('<div class="product-grid">' + "".join(
            product_card_html(p, medal_assets, flags) for p in items
        ) + "</div>")
    else:
        out.append('<div class="empty-state">Nothing here matches that category yet.</div>')
    if active == "all" and detail.total > detail.limit:
        out.append(_airport_pager(path, detail))
    out.append("</section></div></main>")
    return "".join(out)


def _features(detail: AirportDetail) -> dict[str, bool]:
    return feature_flags(
        multi_only=detail.multi_only, awarded_only=detail.awarded_only, exclusives_only=detail.exclusives_only
    )


def category_links_html(heading: str, links) -> str:
    """AirportCategoryPage.tsx `AirportCategoryLinks`: the category-at-airport pages an
    airport has, as chips that are links to pages. Nothing where none is over the bar."""
    if not links:
        return ""
    chips = "".join(
        f'<a class="filter-chip" href="{_a(link.path)}">{_e(link.category)} <span class="chip-count">{link.count:,}</span></a>'
        for link in links
    )
    return (
        '<nav class="airport-categories" aria-label="Category pages at this airport">'
        f'<h2 class="section-heading airport-categories__title">{_e(heading)}</h2>'
        f'<div class="filter-rail">{chips}</div></nav>'
    )


def _airport_chips(path: str, detail: AirportDetail) -> str:
    """The category chips inside the full-list shelf: each a filtered view of
    that shelf, the active one linking back to the unfiltered list."""
    if not detail.categories:
        return ""
    features = _features(detail)
    chips = "".join(
        f'<a class="filter-chip{" filter-chip--active" if c.category == detail.category else ""}" '
        f'href="{_a(path + airport_query(None if c.category == detail.category else c.category, detail.sort, 1, "all", features))}#products">'
        f'{_e(c.category)} <span class="chip-count">{c.count:,}</span></a>'
        for c in detail.categories
    )
    return f'<div class="filter-rail">{chips}</div>'


#: FilterBar.tsx's three feature chips, verbatim, keyed by the /api/products parameter.
FEATURE_LABELS = (("multi_only", "Comparable"), ("awarded_only", "Award winners"), ("exclusives_only", "Travel exclusives"))


def _airport_feature_chips(path: str, detail: AirportDetail) -> str:
    """AirportFilters.tsx: the storefront's own shopping-feature chips on the full list,
    each a link that toggles one feature and keeps the rest (and the category and sort);
    every such view names the clean page as its canonical (head_for_airport)."""
    features = _features(detail)
    chips = []
    for name, label in FEATURE_LABELS:
        on = features.get(name, False)
        toggled = {**features, name: not on}
        href = path + airport_query(detail.category, detail.sort, 1, "all", toggled) + "#products"
        cls = "filter-chip filter-chip--gold filter-chip--active" if on else "filter-chip"
        chips.append(f'<a class="{cls}" href="{_a(href)}"' + (' aria-pressed="true"' if on else ' aria-pressed="false"') + f">{label}</a>")
    return '<div class="filter-rail filter-rail--features">' + "".join(chips) + "</div>"


def _airport_sort_bar(detail: AirportDetail) -> str:
    options = "".join(
        f'<option value="{key}"{" selected" if key == detail.sort else ""}>{label}</option>'
        for key, label in SORT_LABELS
    )
    return (
        '<div class="collection-bar">'
        f'<span class="collection-bar__count">{detail.total:,} {"product" if detail.total == 1 else "products"}</span>'
        f'<label class="collection-bar__sort"><span class="muted">Sort</span>'
        f'<select aria-label="Sort products">{options}</select></label></div>'
    )


def _airport_pager(path: str, detail: AirportDetail) -> str:
    page_no = detail.offset // detail.limit + 1 if detail.limit else 1
    last_page = max(1, -(-detail.total // detail.limit)) if detail.limit else 1

    def link(label: str, target: int, enabled: bool) -> str:
        href = _a(path + airport_query(detail.category, detail.sort, target, "all", _features(detail)) + "#products")
        state = "" if enabled else ' aria-disabled="true" tabindex="-1"'
        return f'<a class="btn btn--ghost" href="{href}"{state}>{label}</a>'

    first = detail.offset + 1
    last = min(detail.offset + detail.limit, detail.total)
    return (
        '<div class="pager">'
        + link("Previous", page_no - 1, page_no > 1)
        + f'<span class="pager__status">{first:,}-{last:,} of {detail.total:,}</span>'
        + link("Next", page_no + 1, page_no < last_page)
        + "</div>"
    )


def _chips_section(heading: str, path: str, view) -> str:
    """The category chips of a hub page: each a filtered view of the same page;
    the active chip links back to the unfiltered list (a toggle), as in the SPA."""
    chips = "".join(
        f'<a class="filter-chip{" filter-chip--active" if c.category == view.category else ""}" '
        f'href="{_a(path + airport_query(None if c.category == view.category else c.category, view.sort, 1))}#products">'
        f'{_e(c.category)} <span class="chip-count">{c.count:,}</span></a>'
        for c in view.categories
    )
    return (
        '<section><div class="product-section-head">'
        f'<h2 class="section-heading">{_e(heading)}</h2></div>'
        f'<div class="filter-rail">{chips}</div></section>'
    )


def _list_section(heading: str, path: str, view, medal_assets: Collection[str], flags: dict[str, bool]) -> str:
    """A hub page's list: one page of cards, the sort control and the pager.
    `view` carries category, sort, total, limit, offset and items (airport and
    brand details alike)."""
    page_no = view.offset // view.limit + 1 if view.limit else 1
    last_page = max(1, -(-view.total // view.limit)) if view.limit else 1
    options = "".join(
        f'<option value="{key}"{" selected" if key == view.sort else ""}>{label}</option>'
        for key, label in SORT_LABELS
    )
    out = [
        '<section id="products"><div class="product-section-head">'
        f'<h2 class="section-heading">{_e(heading)}</h2></div>'
        '<div class="collection-bar">'
        f'<span class="collection-bar__count">{view.total:,} {"product" if view.total == 1 else "products"}</span>'
        f'<label class="collection-bar__sort"><span class="muted">Sort</span><select aria-label="Sort products">{options}</select></label>'
        "</div>"
    ]
    if view.items:
        out.append('<div class="product-grid">' + "".join(
            product_card_html(p, medal_assets, flags) for p in view.items
        ) + "</div>")
    else:
        out.append('<div class="empty-state">Nothing here matches that category yet.</div>')
    if view.total > view.limit:
        def pager_link(label: str, target: int, enabled: bool) -> str:
            href = _a(path + airport_query(view.category, view.sort, target) + "#products")
            state = "" if enabled else ' aria-disabled="true" tabindex="-1"'
            return f'<a class="btn btn--ghost" href="{href}"{state}>{label}</a>'
        first = view.offset + 1
        last = min(view.offset + view.limit, view.total)
        out.append(
            '<div class="pager">'
            + pager_link("Previous", page_no - 1, page_no > 1)
            + f'<span class="pager__status">{first:,}-{last:,} of {view.total:,}</span>'
            + pager_link("Next", page_no + 1, page_no < last_page)
            + "</div>"
        )
    out.append("</section>")
    return "".join(out)


#: CollectionPage.tsx DEFAULT_SORTS, verbatim.
SORT_LABELS = (
    ("featured", "Featured"),
    ("compared", "In most shops"),
    ("price", "Lowest price"),
    ("name", "Name A-Z"),
)


def head_for_airport(
    detail: AirportDetail,
    medal_assets: Collection[str] = (),
    flags: dict[str, bool] | None = None,
    tab: str = "value",
) -> Head:
    """Title, description, CollectionPage + ItemList + Airport markup and the
    rendered body for one airport. A filtered or paged view carries the same
    canonical as the clean page: those views are for people."""
    path = detail.path
    title = f"{detail.name} ({detail.iata}) duty-free prices | {SITE_NAME}"
    description = airport_description(detail)
    airport: dict = {
        "@type": "Airport",
        "@id": f"{path}#airport",
        "name": detail.name,
        "iataCode": detail.iata,
    }
    # Only what the locations row holds: a locality and a country. No street
    # address and no coordinates, because we do not have them.
    if detail.city or detail.country:
        address: dict = {"@type": "PostalAddress"}
        if detail.city:
            address["addressLocality"] = detail.city
        if detail.country:
            address["addressCountry"] = detail.country
        airport["address"] = address
    collection: dict = {
        "@context": "https://schema.org",
        "@type": "CollectionPage",
        "@id": f"{path}#page",
        "url": path,
        "name": f"Duty free at {detail.name}",
        "description": description,
        "isPartOf": {"@id": "/#website"},
        "about": airport,
        "mainEntity": {
            "@type": "ItemList",
            "@id": f"{path}#list",
            "name": f"Products priced at {detail.name}",
            "numberOfItems": detail.total,
            "itemListOrder": "https://schema.org/ItemListUnordered",
            "itemListElement": [
                {
                    "@type": "ListItem",
                    "position": detail.offset + i + 1,
                    "url": product_path(p.id, p.name),
                    "name": p.name,
                }
                for i, p in enumerate(detail.items)
            ],
        },
    }
    breadcrumbs = {
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [
            {"@type": "ListItem", "position": 1, "name": "Airports we price", "item": "/airports"},
            {"@type": "ListItem", "position": 2, "name": detail.name},
        ],
    }
    return Head(
        title=title,
        description=description,
        canonical_path=path,
        jsonld=[collection, breadcrumbs],
        body=airport_body(detail, medal_assets, flags, tab),
        seed=detail.model_dump(mode="json"),
        seed_key="__DFP_AIRPORT__",
        last_modified=detail.last_collected_at,
    )


def airport_head(
    db: Session,
    iata: str,
    medal_assets: Collection[str] = (),
    flags: dict[str, bool] | None = None,
    *,
    category: str | None = None,
    sort: str = "featured",
    page: int = 1,
    limit: int = 24,
    tab: str | None = None,
    multi_only: bool = False,
    awarded_only: bool = False,
    exclusives_only: bool = False,
) -> Head | None:
    """The head and body for one airport page, from the same AirportDetail the
    API hands the SPA, so the two cannot disagree. `tab` is the shelf the URL
    asks for; a filter (a category or a shopping feature), a sort or a page implies
    the full list (`urls.airport_tab`)."""
    features = feature_flags(multi_only=multi_only, awarded_only=awarded_only, exclusives_only=exclusives_only)
    detail = catalog_queries.airport_detail(
        db, iata, category=category, multi_only=multi_only, awarded_only=awarded_only,
        exclusives_only=exclusives_only, sort=sort, limit=limit, offset=(max(page, 1) - 1) * limit,
    )
    if detail is None:
        return None
    return head_for_airport(detail, medal_assets, flags, airport_tab(tab, category, sort, page, features))


# --- category at airport ------------------------------------------------------
#
#   airport_category_body <- pages/AirportCategoryPage.tsx
# Built only where catalog_queries.airport_category_detail answers (the coverage bar,
# read at request time); the address is the airport's own plus the category's word.


def category_word(category: str) -> str:
    """"Whisky" -> "whisky", "Cognac & Brandy" -> "cognac and brandy": the lede's word."""
    return category.lower().replace(" & ", " and ")


def airport_category_lede(detail: AirportCategoryDetail) -> str:
    shops = " and ".join(s.retailer_name for s in detail.shops) or "the airport's shops"
    bits = [
        f"{detail.products:,} {category_word(detail.category)} products priced by {shops} at "
        f"{detail.name} ({detail.iata}), checked {fmt_date(detail.last_collected_at)}."
    ]
    if detail.comparable:
        bits.append(
            f"{detail.comparable:,} of them are sold at other airports we track, and {detail.iata} is "
            f"the cheapest of our airports for {detail.cheapest_here:,}."
        )
    return " ".join(bits)


def airport_category_body(
    detail: AirportCategoryDetail,
    medal_assets: Collection[str] = (),
    flags: dict[str, bool] | None = None,
) -> str:
    """AirportCategoryPage.tsx for the URL asked for: the hero with the standing line, the
    way back to the airport, the airport's other category pages, the exclusives in this
    category here, then the list with the shopping-feature chips, the sort and the pager."""
    flags = flags if flags is not None else {}
    path = detail.path
    where = ", ".join(b for b in (detail.city, detail.country) if b)
    eyebrow = f"{detail.iata} · {where}" if where else detail.iata
    features = feature_flags(
        multi_only=detail.multi_only, awarded_only=detail.awarded_only, exclusives_only=detail.exclusives_only
    )
    out = [
        header_html(flags, active_path="/airports"),
        "<main>",
        '<section class="airports-hero"><div class="airports-hero__inner">',
        '<nav class="crumbs" aria-label="Breadcrumb"><a href="/airports">Airports we price</a>'
        '<span class="crumbs__sep" aria-hidden="true">›</span>'
        f'<a href="{_a(detail.airport_path)}">{_e(detail.name)}</a>'
        f'<span class="crumbs__sep" aria-hidden="true">›</span><span>{_e(detail.category)}</span></nav>',
        f'<span class="eyebrow">{_e(eyebrow)}</span>',
        f'<h1 class="airports-hero__title">{_e(detail.category)} at {_e(detail.name)}</h1>',
        f'<p class="airports-hero__lede">{_e(airport_category_lede(detail))}</p>',
        '<div class="airport-hero__actions">'
        f'<a href="{_a(detail.airport_path)}" class="btn btn--light">All duty free at {_e(detail.name)}</a></div>',
        "</div></section>",
        '<div class="page stack">',
        category_links_html(f"Also at {detail.name}", detail.siblings),
    ]
    if detail.exclusive_items:
        out.append(
            '<section class="airport-category__exclusives"><div class="product-section-head">'
            f'<h2 class="section-heading">{_e(detail.category)} exclusives at {_e(detail.name)}</h2>'
            f'<span class="muted">Sold only in travel retail, as we find them here.</span></div>'
            '<div class="product-grid product-grid--row">'
            + "".join(product_card_html(p, medal_assets, flags) for p in detail.exclusive_items)
            + "</div></section>"
        )
    chips = []
    for name, label in FEATURE_LABELS:
        on = features.get(name, False)
        toggled = {**features, name: not on}
        href = path + airport_query(None, detail.sort, 1, "value", toggled) + "#products"
        cls = "filter-chip filter-chip--gold filter-chip--active" if on else "filter-chip"
        chips.append(f'<a class="{cls}" href="{_a(href)}" aria-pressed="{"true" if on else "false"}">{label}</a>')
    out.append(
        '<section id="products" class="airport-shelf">'
        '<div class="shelf-head"><p class="shelf-head__line muted">'
        f'{_e(f"Every {category_word(detail.category)} product we price at {detail.name}, cheapest first where we can compare.")}</p></div>'
        '<div class="filter-rail filter-rail--features">' + "".join(chips) + "</div>"
    )
    options = "".join(
        f'<option value="{key}"{" selected" if key == detail.sort else ""}>{label}</option>'
        for key, label in SORT_LABELS
    )
    out.append(
        '<div class="collection-bar">'
        f'<span class="collection-bar__count">{detail.total:,} {"product" if detail.total == 1 else "products"}</span>'
        f'<label class="collection-bar__sort"><span class="muted">Sort</span>'
        f'<select aria-label="Sort products">{options}</select></label></div>'
    )
    if detail.items:
        out.append('<div class="product-grid">' + "".join(
            product_card_html(p, medal_assets, flags) for p in detail.items
        ) + "</div>")
    else:
        out.append('<div class="empty-state">Nothing here matches those filters yet.</div>')
    if detail.total > detail.limit:
        page_no = detail.offset // detail.limit + 1
        last_page = max(1, -(-detail.total // detail.limit))

        def link(label: str, target: int, enabled: bool) -> str:
            href = _a(path + airport_query(None, detail.sort, target, "value", features) + "#products")
            state = "" if enabled else ' aria-disabled="true" tabindex="-1"'
            return f'<a class="btn btn--ghost" href="{href}"{state}>{label}</a>'

        first, last = detail.offset + 1, min(detail.offset + detail.limit, detail.total)
        out.append(
            '<div class="pager">' + link("Previous", page_no - 1, page_no > 1)
            + f'<span class="pager__status">{first:,}-{last:,} of {detail.total:,}</span>'
            + link("Next", page_no + 1, page_no < last_page) + "</div>"
        )
    out.append("</section></div></main>")
    return "".join(out)


def head_for_airport_category(
    detail: AirportCategoryDetail,
    medal_assets: Collection[str] = (),
    flags: dict[str, bool] | None = None,
) -> Head:
    """Title, description, CollectionPage about the Airport with an ItemList of the category's
    products, three-level breadcrumbs, and the body. A filtered or paged view keeps the clean
    pair address as its canonical."""
    path = detail.path
    title = f"{detail.category} at {detail.name} ({detail.iata}) duty-free prices | {SITE_NAME}"
    description = _truncate(airport_category_lede(detail))
    airport: dict = {"@type": "Airport", "@id": f"{detail.airport_path}#airport", "name": detail.name, "iataCode": detail.iata}
    collection: dict = {
        "@context": "https://schema.org",
        "@type": "CollectionPage",
        "@id": f"{path}#page",
        "url": path,
        "name": f"{detail.category} at {detail.name}",
        "description": description,
        "isPartOf": {"@id": "/#website"},
        "about": airport,
        "mainEntity": {
            "@type": "ItemList",
            "@id": f"{path}#list",
            "name": f"{detail.category} priced at {detail.name}",
            "numberOfItems": detail.total,
            "itemListOrder": "https://schema.org/ItemListUnordered",
            "itemListElement": [
                {"@type": "ListItem", "position": detail.offset + i + 1, "url": product_path(p.id, p.name), "name": p.name}
                for i, p in enumerate(detail.items)
            ],
        },
    }
    breadcrumbs = {
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [
            {"@type": "ListItem", "position": 1, "name": "Airports we price", "item": "/airports"},
            {"@type": "ListItem", "position": 2, "name": detail.name, "item": detail.airport_path},
            {"@type": "ListItem", "position": 3, "name": detail.category},
        ],
    }
    return Head(
        title=title,
        description=description,
        canonical_path=path,
        jsonld=[collection, breadcrumbs],
        body=airport_category_body(detail, medal_assets, flags),
        seed=detail.model_dump(mode="json"),
        seed_key="__DFP_AIRPORT_CATEGORY__",
        last_modified=detail.last_collected_at,
    )


def airport_category_head(
    db: Session,
    iata: str,
    category_slug_: str,
    medal_assets: Collection[str] = (),
    flags: dict[str, bool] | None = None,
    *,
    sort: str = "featured",
    page: int = 1,
    limit: int = 24,
    multi_only: bool = False,
    awarded_only: bool = False,
    exclusives_only: bool = False,
) -> Head | None:
    """The head and body for one pairing page, from the same object the API hands the SPA;
    None where the pair has no page (an unknown category word, or under the bar)."""
    category = category_from_slug(category_slug_)
    if category is None:
        return None
    detail = catalog_queries.airport_category_detail(
        db, iata, category, multi_only=multi_only, awarded_only=awarded_only, exclusives_only=exclusives_only,
        sort=sort, limit=limit, offset=(max(page, 1) - 1) * limit,
    )
    if detail is None:
        return None
    return head_for_airport_category(detail, medal_assets, flags)


# --- brand pages ----------------------------------------------------------------
#
#   brand_body <- pages/BrandPage.tsx (default state)
# Keyed by the brands table's slug (never a folded name); an alias 301s to its
# house in the route. Same mirror rule as the airport page.


def brand_lede(detail: BrandDetail) -> str:
    airports = len(detail.airports)
    bits = [
        f"{detail.products:,} {detail.name} products priced at "
        f"{airports} airport{'s' if airports != 1 else ''} we track, checked {fmt_date(detail.last_collected_at)}."
    ]
    if detail.comparable:
        best = max(detail.airports, key=lambda a: (a.cheapest_for, a.products), default=None)
        bits.append(
            f"{detail.comparable:,} of them are sold at more than one, so you can see which airport is "
            f"the better buy"
            + (f"; most often it is {best.iata}." if best and best.cheapest_for else ".")
        )
    return " ".join(bits)


def brand_description(detail: BrandDetail) -> str:
    airports = len(detail.airports)
    bits = [f"{detail.products:,} {detail.name} duty-free prices at {airports} airport{'s' if airports != 1 else ''}, "
            f"checked {fmt_date(detail.last_collected_at)}."]
    if detail.comparable:
        bits.append(f"{detail.comparable:,} compared across airports: see where {detail.name} is the better buy.")
    return _truncate(" ".join(bits))


def brand_body(
    detail: BrandDetail,
    medal_assets: Collection[str] = (),
    flags: dict[str, bool] | None = None,
) -> str:
    """BrandPage.tsx in its default state, as static HTML for the root element."""
    flags = flags if flags is not None else {}
    path = detail.path
    airports = "".join(
        f'<li class="brand-airport"><a class="brand-airport__name" href="{_a(a.path)}">{_e(a.name)} ({_e(a.iata)})</a>'
        f'<span class="brand-airport__facts">{a.products:,} {"product" if a.products == 1 else "products"}'
        + (f" · cheapest for {a.cheapest_for:,}" if a.cheapest_for else "")
        + f" · checked {_e(fmt_date(a.last_collected_at))}</span></li>"
        for a in detail.airports
    )
    out = [
        # No nav item is active here: react-router's NavLink matches /products only by prefix.
        header_html(flags),
        "<main>",
        '<section class="airports-hero"><div class="airports-hero__inner">',
        '<nav class="crumbs" aria-label="Breadcrumb"><a href="/products">Products</a>'
        f'<span class="crumbs__sep" aria-hidden="true">›</span><span>{_e(detail.name)}</span></nav>',
        '<span class="eyebrow">Brand</span>',
        f'<h1 class="airports-hero__title">{_e(detail.name)} in duty free</h1>',
        f'<p class="airports-hero__lede">{_e(brand_lede(detail))}</p>',
        "</div></section>",
        '<div class="page stack">',
        '<dl class="airport-stats">',
        f'<div class="airport-stat"><dt>Products priced</dt><dd>{detail.products:,}</dd></div>',
        f'<div class="airport-stat"><dt>Airports stocking it</dt><dd>{len(detail.airports):,}</dd></div>',
        f'<div class="airport-stat"><dt>Compared across airports</dt><dd>{detail.comparable:,}</dd></div>',
        f'<div class="airport-stat"><dt>Travel exclusives</dt><dd>{detail.exclusives:,}</dd></div>',
        "</dl>",
        '<section><div class="product-section-head">'
        f'<h2 class="section-heading">Where to buy {_e(detail.name)}</h2>'
        '<span class="muted">Airports we track that stock it, and how often each is the cheapest</span></div>'
        f'<ul class="brand-airports">{airports}</ul></section>',
    ]
    if len(detail.categories) > 1:
        out.append(_chips_section(f"{detail.name} by category", path, detail))
    heading = (
        f"{detail.name} {detail.category.lower()}" if detail.category
        else f"Every {detail.name} product we price"
    )
    out.append(_list_section(heading, path, detail, medal_assets, flags))
    out.append("</div></main>")
    return "".join(out)


def head_for_brand(
    detail: BrandDetail, medal_assets: Collection[str] = (), flags: dict[str, bool] | None = None
) -> Head:
    """Title, description, CollectionPage + Brand + ItemList markup and the
    rendered body for one brand; filtered views canonicalise to the clean page."""
    path = detail.path
    title = f"{detail.name} duty-free prices | {SITE_NAME}"
    description = brand_description(detail)
    collection: dict = {
        "@context": "https://schema.org",
        "@type": "CollectionPage",
        "@id": f"{path}#page",
        "url": path,
        "name": f"{detail.name} in duty free",
        "description": description,
        "isPartOf": {"@id": "/#website"},
        "about": {"@type": "Brand", "@id": f"{path}#brand", "name": detail.name, "url": path},
        "mainEntity": {
            "@type": "ItemList",
            "@id": f"{path}#list",
            "name": f"{detail.name} products priced in duty free",
            "numberOfItems": detail.total,
            "itemListOrder": "https://schema.org/ItemListUnordered",
            "itemListElement": [
                {"@type": "ListItem", "position": detail.offset + i + 1,
                 "url": product_path(p.id, p.name), "name": p.name}
                for i, p in enumerate(detail.items)
            ],
        },
    }
    breadcrumbs = {
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [
            {"@type": "ListItem", "position": 1, "name": "Products", "item": "/products"},
            {"@type": "ListItem", "position": 2, "name": detail.name},
        ],
    }
    return Head(
        title=title,
        description=description,
        canonical_path=path,
        jsonld=[collection, breadcrumbs],
        body=brand_body(detail, medal_assets, flags),
        seed=detail.model_dump(mode="json"),
        seed_key="__DFP_BRAND__",
        last_modified=detail.last_collected_at,
    )


def brand_head(
    db: Session,
    slug: str,
    medal_assets: Collection[str] = (),
    flags: dict[str, bool] | None = None,
    *,
    category: str | None = None,
    sort: str = "featured",
    page: int = 1,
    limit: int = 24,
) -> Head | None:
    detail = catalog_queries.brand_detail(
        db, slug, category=category, sort=sort, limit=limit, offset=(max(page, 1) - 1) * limit
    )
    if detail is None:
        return None
    return head_for_brand(detail, medal_assets, flags)


# --- articles -----------------------------------------------------------------
#
#   article_body <- pages/ArticlePage.tsx; editorial_block_html <- components/EditorialBlock.tsx
# Stream D's text (services/editorial.py: published rows only, the Markdown
# rendered once by services/markdown.py so the API and this body cannot
# differ). Omitted on purpose at the foot: the subscribe form, a JavaScript
# form whose ids React mints at mount; the "All articles" link stays.


def updated_after_published(published: datetime | None, updated: datetime | None) -> bool:
    """ArticlePage.tsx updatedAfterPublished: an edit on the day of publication is
    not an update; only a later UTC calendar day is."""
    if published is None or updated is None:
        return False
    return updated.astimezone(UTC).date() > published.astimezone(UTC).date()


#: lib/articles.ts HOUSE_BYLINE: the byline of a piece with no author account.
HOUSE_BYLINE = "Duty Free Professor"
#: ArticlePage.tsx's publisher note, verbatim.
_ARTICLE_ABOUT = (
    "We compare the public shelf prices of airport duty-free shops, bottle by bottle and "
    "dated, and set beside them the medals from the Professor's own wine and spirits "
    "competitions."
)


def article_meta(out: ArticleOut) -> str:
    """lib/articles.ts bylineText(article, true): "date · author · N min read"."""
    parts = [fmt_date(out.published_at), out.author or HOUSE_BYLINE, f"{out.reading_minutes} min read"]
    if updated_after_published(out.published_at, out.updated_at):
        parts.append(f"Updated {fmt_date(out.updated_at)}")
    return " · ".join(parts)


def editorial_block_html(out: ArticleOut) -> str:
    """EditorialBlock.tsx: the Professor's note on an airport (or category) page."""
    return (
        '<section class="editorial-block"><span class="eyebrow">From the Professor</span>'
        f'<h2 class="editorial-block__title">{_e(out.title)}</h2>'
        f'<div class="prose">{out.body_html}</div></section>'
    )


def article_body(out: ArticleOut, flags: dict[str, bool] | None = None) -> str:
    """ArticlePage.tsx as static HTML for the root element, class for class."""
    flags = flags if flags is not None else {}
    parts = [
        header_html(flags, active_path="/articles"),
        "<main>",
        '<article class="article-page"><header class="article-page__head">',
        '<a class="eyebrow eyebrow--ruled article-page__kicker" href="/articles">'
        f'{_e(out.category or "Article")}</a>',
        f'<h1 class="article-page__title">{_e(out.title)}</h1>',
        f'<p class="article-page__standfirst">{_e(out.standfirst)}</p>' if out.standfirst else "",
        f'<p class="article-page__meta">{_e(article_meta(out))}</p>',
        "</header>",
        f'<figure class="article-page__hero"><img src="{_a(out.hero_image)}" alt="" /></figure>'
        if out.hero_image else '<hr class="article-page__rule" />',
        # The text centred, no skyscraper rail: the page as it is with no sponsor
        # creative (a reviewer's preview adds the rail in that browser only).
        '<div class="article-page__layout"><div class="article-page__main">',
        f'<div class="prose prose--article">{out.body_html}</div>',
        '<aside class="article-page__about"><span class="eyebrow">About Duty Free Professor</span>'
        f"<p>{_e(_ARTICLE_ABOUT)}</p></aside></div></div>",
        # The sponsor strip renders nothing without creative; "more from the
        # Professor" is fetched after mount, below everything a reader sees first.
        '<div class="article-page__sponsors"></div>',
        '<footer class="article-page__foot"><a href="/articles" class="btn btn--ghost">All articles</a></footer>',
        "</article></main>",
    ]
    return "".join(parts)


def head_for_article(out: ArticleOut, flags: dict[str, bool] | None = None) -> Head:
    """Title, description, Article + BreadcrumbList markup and the rendered body
    for one published piece; the seed lets the SPA draw it without a request."""
    path = out.path
    article: dict = {
        "@context": "https://schema.org",
        "@type": "Article",
        "@id": f"{path}#article",
        "url": path,
        "mainEntityOfPage": {"@type": "WebPage", "@id": path},
        "headline": out.title,
        "description": out.description,
        "isPartOf": {"@id": "/#website"},
        "publisher": {"@id": "/#organization"},
    }
    if out.published_at:
        article["datePublished"] = out.published_at.astimezone(UTC).isoformat()
    if out.updated_at:
        article["dateModified"] = out.updated_at.astimezone(UTC).isoformat()
    if out.category:
        article["articleSection"] = out.category
    if out.hero_image:
        article["image"] = out.hero_image
    breadcrumbs = {
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [
            {"@type": "ListItem", "position": 1, "name": "Articles", "item": "/articles"},
            {"@type": "ListItem", "position": 2, "name": out.title},
        ],
    }
    return Head(
        title=f"{out.title} | {SITE_NAME}",
        description=_truncate(out.description),
        canonical_path=path,
        jsonld=[article, breadcrumbs],
        image=out.hero_image,
        body=article_body(out, flags),
        seed=out.model_dump(mode="json"),
        seed_key="__DFP_ARTICLE__",
        last_modified=out.updated_at or out.published_at,
        og_type="article",
    )


def article_head(db: Session, slug: str, flags: dict[str, bool] | None = None) -> Head | None:
    """None for a draft or an unknown slug, so the route answers a real 404."""
    row = editorial.article_by_slug(db, slug)
    if row is None:
        return None
    return head_for_article(editorial.article_out(row), flags)


# --- the dataset page ---------------------------------------------------------
#
#   dataset_body <- pages/DataPage.tsx
# A public description of what we collect, for people and for the Dataset
# vocabulary. Facts about the collection only: every figure is counted at
# request time (`catalog_queries.dataset_facts`) and the method is stated as
# the collectors actually behave (main/docs/COLLECTORS.md is the authority).

DATASET_NAME = "Airport duty-free price observations"
DATASET_SUMMARY = (
    "Dated observations of the public list prices at airport duty-free shops: for each product "
    "at each shop, the price in the shop's currency and in US dollars, the stock signal where the "
    "shop shows one, and the listing it was read from."
)
#: What one row holds, as (field, meaning). The page and the Dataset markup both read this.
DATASET_VARIABLES = (
    ("product", "the bottle or item, keyed on its barcode where the shop publishes one, else on brand, name and size"),
    ("shop", "the airport storefront (retailer and airport) the price was read at"),
    ("price", "the list price shown to a departing shopper, in the shop's currency; a crossed-out price is kept as was-price"),
    ("price in USD", "our conversion at the day's rate, so shops can be compared; the shop's figure is the observation"),
    ("in stock", "the shop's own stock signal, when it shows one; otherwise unknown, never assumed"),
    ("observed at", "the minute the listing was read"),
    ("listing URL", "the shop's own page for the product, which every price links back to"),
)
DATASET_METHOD = (
    "Read from retailers' public listing pages by our own identified reader, DutyFreeProfessorBot "
    "(https://bot.dutyfreeprofessor.com). Each host's robots.txt is re-read on every run and any "
    "matching disallow is a refusal; the reader never logs in, never creates an account and never "
    "accepts terms; listing pages are read rather than product pages; a shop that blocks the reader "
    "is left alone and its prices stay shown with their date."
)


def dataset_body(facts: DatasetFacts, flags: dict[str, bool] | None = None) -> str:
    """DataPage.tsx as static HTML: the same sections, the same figures."""
    flags = flags if flags is not None else {}
    stats = (
        ("Price observations", f"{facts.observations:,}"),
        ("Products", f"{facts.products:,}"),
        ("Airports", f"{facts.airports:,}"),
        ("Currencies", f"{facts.currencies:,}"),
        ("Products with a barcode", f"{facts.with_barcode:,}"),
        ("Competition medals attached", f"{facts.awards:,}"),
    )
    airports = "".join(
        f'<li><a href="{_a(a.path)}">{_e(a.name)} ({_e(a.iata)})</a>: {a.products:,} products, '
        f"checked {_e(fmt_date(a.last_collected_at))}</li>"
        for a in facts.airport_list
    )
    categories = "".join(
        f'<li><a href="{_a(category_path(c.category))}">{_e(c.category)}</a>: {c.count:,}</li>'
        for c in facts.categories
    )
    variables = "".join(
        f"<tr><th scope=\"row\">{_e(field)}</th><td>{_e(meaning)}</td></tr>" for field, meaning in DATASET_VARIABLES
    )
    return (
        header_html(flags)
        + "<main>"
        '<section class="airports-hero"><div class="airports-hero__inner">'
        '<span class="eyebrow">The data</span>'
        f'<h1 class="airports-hero__title">{_e(DATASET_NAME)}</h1>'
        f'<p class="airports-hero__lede">{_e(DATASET_SUMMARY)}</p>'
        "</div></section>"
        '<div class="page stack">'
        '<dl class="airport-stats">'
        + "".join(f'<div class="airport-stat"><dt>{_e(k)}</dt><dd>{v}</dd></div>' for k, v in stats)
        + "</dl>"
        f'<p class="airport-shops muted">Observed from {_e(fmt_date(facts.first_observed_at))} to '
        f'{_e(fmt_date(facts.last_observed_at))}, at visible airports; the figures are counted when this page is served.</p>'
        '<section class="data-section"><h2 class="section-heading">How it is collected</h2>'
        f'<p>{_e(DATASET_METHOD)}</p>'
        "<p>We store facts, not expression: prices, sizes, barcodes, stock and brands. No retailer "
        "descriptions and no retailer photography are copied.</p></section>"
        '<section class="data-section"><h2 class="section-heading">What one observation holds</h2>'
        f'<div class="scroll-x"><table class="data-table"><tbody>{variables}</tbody></table></div></section>'
        '<section class="data-section"><h2 class="section-heading">Coverage</h2>'
        f'<ul class="data-list">{airports}</ul>'
        f'<h3 class="data-subheading">Categories</h3><ul class="data-list data-list--inline">{categories}</ul></section>'
        '<section class="data-section"><h2 class="section-heading">Using it</h2>'
        "<p>A price is an observation with a date, never a quote: duty-free pricing varies by destination, "
        "loyalty tier and traveller. Link to the product or airport page rather than copying a figure, so "
        "the date travels with it. There is no bulk download; the sitemap lists every page and the feed "
        "lists what is new.</p>"
        '<p>Questions about the data, or a shop that would rather not be read: <a href="mailto:bot@dutyfreeprofessor.com">bot@dutyfreeprofessor.com</a>.</p>'
        '<p class="data-links"><a href="/sitemap.xml">Sitemap</a> · <a href="/feed.xml">Feed</a> · '
        '<a href="/llms.txt">llms.txt</a> · <a href="/airports">Airports we price</a></p></section>'
        "</div></main>"
    )


def head_for_dataset(facts: DatasetFacts, flags: dict[str, bool] | None = None) -> Head:
    """The public data page with schema.org Dataset markup. No `license` and no
    `distribution` are declared: neither is decided, and an invented one would
    be a promise."""
    coverage = (
        f"{facts.first_observed_at.date().isoformat()}/{facts.last_observed_at.date().isoformat()}"
        if facts.first_observed_at and facts.last_observed_at else None
    )
    dataset: dict = {
        "@context": "https://schema.org",
        "@type": "Dataset",
        "@id": "/data#dataset",
        "name": f"{SITE_NAME}: {DATASET_NAME}",
        "url": "/data",
        "description": DATASET_SUMMARY,
        "creator": {"@id": "/#organization"},
        "publisher": {"@id": "/#organization"},
        "isAccessibleForFree": True,
        "inLanguage": "en",
        "keywords": ["duty free", "airport shopping", "price comparison", "travel retail", "spirits", "wine"],
        "measurementTechnique": DATASET_METHOD,
        "variableMeasured": [
            {"@type": "PropertyValue", "name": field, "description": meaning}
            for field, meaning in DATASET_VARIABLES
        ],
        "spatialCoverage": [
            {"@type": "Airport", "@id": f"{a.path}#airport", "name": a.name, "iataCode": a.iata, "url": a.path}
            for a in facts.airport_list
        ],
        "size": f"{facts.observations:,} price observations across {facts.products:,} products",
    }
    if coverage:
        dataset["temporalCoverage"] = coverage
    if facts.last_observed_at:
        dataset["dateModified"] = facts.last_observed_at.date().isoformat()
    breadcrumbs = {
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [
            {"@type": "ListItem", "position": 1, "name": SITE_NAME, "item": "/"},
            {"@type": "ListItem", "position": 2, "name": "The data"},
        ],
    }
    description = _truncate(
        f"{facts.observations:,} dated price observations across {facts.products:,} products at "
        f"{facts.airports} airports: what we collect, how, and how to read it."
    )
    return Head(
        title=f"The data: {DATASET_NAME.lower()} | {SITE_NAME}",
        description=description,
        canonical_path="/data",
        jsonld=[dataset, breadcrumbs],
        body=dataset_body(facts, flags),
        seed=facts.model_dump(mode="json"),
        seed_key="__DFP_DATASET__",
        last_modified=facts.last_observed_at,
    )


def dataset_head(db: Session, flags: dict[str, bool] | None = None) -> Head:
    return head_for_dataset(catalog_queries.dataset_facts(db), flags)


def organization_jsonld(same_as: Collection[str] = ()) -> dict:
    """The Organization, with `sameAs` for the social profiles that exist (the
    same settings the header and footer links read, so the two cannot differ)."""
    org: dict = {
        "@context": "https://schema.org",
        "@type": "Organization",
        "@id": "/#organization",
        "name": SITE_NAME,
        "url": "/",
        "logo": "/logo-duty-free-professor.png",
    }
    if same_as:
        org["sameAs"] = list(same_as)
    return org


ORGANIZATION = organization_jsonld(settings.social_profiles.values())

# WebSite without SearchAction: Google retired the sitelinks search box in
# November 2024, so the action is dead weight that some validators now flag.
WEBSITE = {
    "@context": "https://schema.org",
    "@type": "WebSite",
    "@id": "/#website",
    "name": SITE_NAME,
    "url": "/",
    "publisher": {"@id": "/#organization"},
}

# Static heads for the app's main routes. /discuss and the demo controls carry
# noindex: they are for the client and us, not for search.
STATIC_HEADS: dict[str, Head] = {
    "/": Head(DEFAULT_TITLE, DEFAULT_DESCRIPTION, canonical_path="/", jsonld=[ORGANIZATION, WEBSITE]),
    "/products": Head(
        f"Every bottle we track: duty-free price comparison | {SITE_NAME}",
        "Browse every duty-free product we price: whisky, cognac, gin, vodka, wine and more, with live prices across airport shops worldwide.",
        canonical_path="/products",
    ),
    "/savings": Head(
        f"Your savings: where to buy on your trip | {SITE_NAME}",
        "Pick the airports you'll be at and see every bottle stocked at more than one of them, ranked by how much the choice is worth.",
        canonical_path="/savings",
    ),
    "/exclusives": Head(
        f"Travel-retail exclusives | {SITE_NAME}",
        "Bottles you can only buy in an airport: travel-retail exclusive whiskies, gins and more, with live duty-free prices.",
        canonical_path="/exclusives",
    ),
    "/awards": Head(
        f"Award-winning bottles in duty free | {SITE_NAME}",
        "Competition medal winners on duty-free shelves right now, with the medal and the live price side by side.",
        canonical_path="/awards",
    ),
    "/airports": Head(
        f"Airports we price | {SITE_NAME}",
        "Every airport duty-free shop we collect prices from, how fresh each one is, and which operators publish nothing.",
        canonical_path="/airports",
    ),
    "/articles": Head(
        f"Articles | {SITE_NAME}",
        "Guides, news and notes from the Duty Free Professor, written to sit next to the live duty-free prices.",
        canonical_path="/articles",
    ),
    "/discuss": Head(f"Proof of concept review | {SITE_NAME}", "Project review page.", noindex=True),
    "/quote": Head(f"Soft launch proposal | {SITE_NAME}", "Project proposal.", noindex=True),
    "/structure": Head(f"Site structure proposal | {SITE_NAME}", "Structure proposal.", noindex=True),
    "/settings": Head(f"Demo settings | {SITE_NAME}", "Demo controls.", noindex=True),
    "/todo": Head(f"Your to-do list | {SITE_NAME}", "What we need from you.", noindex=True),
    "/issues": Head(f"Issues register | {SITE_NAME}", "Issues register.", noindex=True),
    "/plan": Head(f"Build plan | {SITE_NAME}", "Build plan.", noindex=True),
    "/sources": Head(f"Data sources | {SITE_NAME}", "Collection status.", noindex=True),
    "/images": Head(f"Product images | {SITE_NAME}", "Image sourcing list.", noindex=True),
    "/collectors": Head(f"Collectors and catalogue | {SITE_NAME}", "Internal data review.", noindex=True),
    # The account pages (ACCOUNTS.md): never indexed, whatever the site mode.
    "/login": Head(f"Sign in | {SITE_NAME}", "Sign in to Duty Free Professor.", noindex=True),
    "/forgot": Head(f"Forgot password | {SITE_NAME}", "Ask for a reset link.", noindex=True),
    "/welcome": Head(f"Welcome | {SITE_NAME}", "Choose your password.", noindex=True),
    "/reset": Head(f"Reset password | {SITE_NAME}", "Choose a new password.", noindex=True),
    "/account": Head(f"Your account | {SITE_NAME}", "Your account.", noindex=True),
    "/admin": Head(f"Accounts | {SITE_NAME}", "People and levels.", noindex=True),
}


NOT_FOUND_HEAD = Head(f"Page not found | {SITE_NAME}", "There is no page at this address.", noindex=True)

# Routes the SPA serves that have no head of their own. Mirrors App.tsx; a
# test reads App.tsx and fails if the two drift. Anything not listed here, in
# STATIC_HEADS or under /products/ is a real 404, not a 200 with the shell.
_SHELL_ROUTES = {"/trip"}
_SHELL_PREFIXES = ("/feature/",)
#: Pages with a route of their own ahead of the shell, whose head comes from the database.
_PAGE_ROUTES = {"/data"}


def is_known_route(path: str) -> bool:
    path = path.rstrip("/") or "/"
    if path in STATIC_HEADS or path in _SHELL_ROUTES or path in _PAGE_ROUTES:
        return True
    return any(path.startswith(prefix) and len(path) > len(prefix) for prefix in _SHELL_PREFIXES)


def sitemap_xml(db: Session, base: str, include_my_airports: bool = True) -> str:
    """Every indexable URL, straight from the database."""
    return sitemap_entries(db, base, include_my_airports)[0]


def _product_sitemap_rows(db: Session) -> list[tuple[int, str, datetime | None]]:
    """Every product with an observation at a shop the site shows, and its newest one."""
    return [
        (pid, name, last)
        for pid, name, last in db.execute(
            select(Product.id, Product.name, func.max(PriceObservation.observed_at))
            .join(Listing, Listing.product_id == Product.id)
            .join(PriceObservation, PriceObservation.listing_id == Listing.id)
            .join(Location, Location.id == Listing.location_id)
            .where(catalog_queries.publishable(db), catalog_queries.shown_category())
            .group_by(Product.id)
        ).all()
    ]


def sitemap_entries(db: Session, base: str, include_my_airports: bool = True) -> tuple[str, datetime | None]:
    """The sitemap XML and the newest lastmod in it (the document's Last-Modified)."""
    static_paths = ["/", "/products", "/exclusives", "/awards", "/airports", "/articles", "/data"]
    if include_my_airports:
        static_paths.insert(2, "/savings")
    entries: list[str] = [
        f"<url><loc>{html.escape(base + path)}</loc></url>" for path in static_paths
    ]
    newest: datetime | None = None
    # Hub pages first (airports; brands and categories join them), then every
    # product. A hub's lastmod is its newest observation, like a product's.
    dated: list[tuple[str, datetime | None]] = [
        (airport.path, airport.last_collected_at) for airport in catalog_queries.list_airports(db)
    ]
    dated.extend(catalog_queries.category_page_rows(db))
    dated.extend((brand.path, brand.last_collected_at) for brand in catalog_queries.list_brands(db))
    # Published articles, lastmod = the last edit (editorial.sitemap_rows).
    dated.extend(editorial.sitemap_rows(db))
    dated.extend((product_path(pid, name), last) for pid, name, last in _product_sitemap_rows(db))
    for path, last in dated:
        lastmod = (last or datetime.now(UTC)).strftime("%Y-%m-%d")
        if last is not None and (newest is None or last > newest):
            newest = last
        entries.append(f"<url><loc>{html.escape(base + path)}</loc><lastmod>{lastmod}</lastmod></url>")
    body = "".join(entries)
    xml = (
        '<?xml version="1.0" encoding="UTF-8"?>'
        '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
        f"{body}</urlset>"
    )
    return xml, newest
