"""Avolta storefronts — World Duty Free and Shop Duty Free.

Avolta is the world's largest travel retailer and runs one commerce platform
across 168 airports, each on its own subdomain. That makes it the single
biggest source of per-airport prices available.

**On politeness, because it matters here.** Each storefront publishes a
robots.txt that permits product and category paths and asks for a crawl delay
(30 seconds at some airports, 60 at others). This collector reads each host's
own robots.txt and honours whichever delay that host asks for, taking the
slower of that and our own setting. It never touches the paths they disallow —
search, facets, query strings, checkout, customer pages. Nothing here works
around a refusal: where the site says no, we do not go, and if a storefront
ever starts declining us the fetch layer raises SourceBlocked and the run
stops rather than escalating.

Coverage is deliberately bounded to a configured list of airports and a page
budget per airport, because the point is breadth of shops rather than
exhausting any one catalogue.

Two trees are walked, each with its own slug list and its own cap (an
alphabetical cap shared across trees once dropped whisky and wine at Heathrow
because "beauty" sorts first). Drinks are paged; beauty is read one listing
page deep and its product pages are fetched only for the targeted lines
(Decision 4, `collectors/targets.py`). Four stores forbid any query string, so
category pages beyond the first are never requested there; instead the product
URLs the database already holds for the store are re-read, one fetch each, so
a bottle that lives on page 2 keeps its price history.
"""

import json
import re
from collections.abc import Iterator
from dataclasses import dataclass
from html import unescape

import logging

from app.services.collectors.base import Collector, RawListing, ShopSpec, facts_only, gone, listing_ref, report_skip
from app.services.collectors.fetch import FetchError, SourceBlocked, fetch
from app.services.collectors.robots import Robots, check_allowed
from app.services.collectors.targets import BeautyTargets, load_beauty_targets
from app.services.normalize import gtin_from_sku, parse_abv, parse_quantity_ml, parse_quantity_ml_from_url
from app.services.taxonomy import vertical_from_hints

PARSER_VERSION = "avolta/2026-09-18"

logger = logging.getLogger(__name__)

# Category page markup (Magento with microdata).
_ITEM_SPLIT = re.compile(r'class="product-item-info')
_URL_RE = re.compile(r'<a class="product-item-link" href="([^"]+)"')
_NAME_RE = re.compile(r'<a class="product-item-link"[^>]*>\s*(.*?)\s*(?:<span|</a>)', re.S)
_BRAND_RE = re.compile(r"product-brand[^>]*>\s*<div class='logo-text'>(.*?)</div>", re.S)
# The FIRST data-price-amount in a tile is whatever Magento renders first --
# on a promoted product that is the pre-promotion oldPrice, which once shipped
# a 20-percent-off Laphroaig at its full price. Target the typed blocks.
_PRICE_RE = re.compile(r'data-price-amount="([0-9.]+)"')
_FINAL_PRICE_RE = re.compile(
    r'data-price-type="finalPrice"[^>]*data-price-amount="([0-9.]+)"'
    r'|data-price-amount="([0-9.]+)"[^>]*data-price-type="finalPrice"'
)
_OLD_PRICE_RE = re.compile(
    r'data-price-type="oldPrice"[^>]*data-price-amount="([0-9.]+)"'
    r'|data-price-amount="([0-9.]+)"[^>]*data-price-type="oldPrice"'
)


def _typed_price(pattern: re.Pattern[str], chunk: str) -> float | None:
    match = pattern.search(chunk)
    if not match:
        return None
    return float(match.group(1) or match.group(2))
_SKU_RE = re.compile(r'data-product-sku="([^"]+)"')
_SYMBOL_RE = re.compile(r'<span class="price">\s*([^\d\s<]{1,3})')
_TAG_RE = re.compile(r"<[^>]+>")

# Category links on a storefront home page, per tree. The slug varies by store
# ("liquor" at Heathrow, "wines-spirits" at Madrid), so each tree matches any
# of its spellings. Each tree is walked separately with its own cap.
_DRINKS_SLUGS = (
    "liquor", "wines-spirits", "wine-spirits", "spirits-wines", "spirits",
    "wines", "drinks", "beverages", "alcohol",
)
_BEAUTY_SLUGS = (
    "beauty", "fragrance", "fragrances", "perfume", "perfumes", "perfumery",
    "skincare", "skin-care", "cosmetics", "make-up", "makeup", "beauty-fragrance",
    "fragrance-beauty", "beauty-and-fragrance", "perfumes-cosmetics",
)
VERTICAL_SLUGS: dict[str, tuple[str, ...]] = {"liquor": _DRINKS_SLUGS, "beauty": _BEAUTY_SLUGS}


def _link_re(slugs: tuple[str, ...]) -> re.Pattern[str]:
    return re.compile(
        r'href="(https://[a-z0-9.-]+/[a-z]{2}/\d+/(?:' + "|".join(slugs) + r')(?:/[a-z0-9-]+)*)/?"'
    )


_LINK_RE: dict[str, re.Pattern[str]] = {v: _link_re(slugs) for v, slugs in VERTICAL_SLUGS.items()}
_DRINKS_LINK_RE = _LINK_RE["liquor"]
# "liquor-finder" and similar tools are not product listings.
_NOT_A_CATEGORY = re.compile(r"(finder|discover|guide|terms|boutique)", re.I)

SYMBOL_CURRENCIES = {
    "£": "GBP", "€": "EUR", "$": "USD", "CHF": "CHF", "kr": "SEK",
    "HK": "HKD", "C$": "CAD", "R$": "BRL", "₹": "INR",
}

# Magento states the currency its price attributes are denominated in. Trust
# that over a "$" glyph: several of these stores quote in USD while displaying
# the local currency beside it, and a bare "$" cannot tell them apart.
_DECLARED_CURRENCY_RE = re.compile(
    r'itemprop=["\']priceCurrency["\'][^>]*content=["\']([A-Z]{3})["\']'
    r'|["\']priceCurrency["\']\s*:\s*["\']([A-Z]{3})["\']'
)


def declared_currency(html: str) -> str | None:
    """The currency the page itself says its price attributes are in."""
    match = _DECLARED_CURRENCY_RE.search(html)
    if not match:
        return None
    return match.group(1) or match.group(2)

# Caps are per store AND per tree: every discovered category of a tree is
# walked up to the tree's cap (an alphabetical cap of 8 across everything once
# silently dropped whisky, white-spirits and wine at Heathrow -- Johnnie Walker
# Blue, the client's own canonical example, was never collected), and a cap
# that drops anything says so in the log, naming the store. Drinks categories
# page through ?p=N until a page adds nothing new; beauty reads page 1 only.
MAX_CATEGORIES_PER_VERTICAL = 24
MAX_PAGES_PER_CATEGORY = 6
# A multi-size ("configurable") tile advertises its CHEAPEST variant: Heathrow
# lists Johnnie Walker Blue Label 1L at the 20cl price. The real per-size price
# is only on the product page, so those tiles get one extra fetch each. Bounded
# because each costs a full crawl delay; beauty has its own budget so the
# targeted lines are never crowded out by the drinks queue.
MAX_VARIANT_LOOKUPS = 40
MAX_BEAUTY_LOOKUPS = 40
# ProductVariant URLs already held for a page-1-only store, re-read per run so page-2+
# product_variants keep their history. One fetch each at the host's crawl delay.
MAX_HELD_REREADS = 150
# Magento marks a configurable product's parent SKU with this suffix.
_CONFIGURABLE_SUFFIX = "-P"

_ATTRIBUTES_RE = re.compile(r'"attributes"\s*:\s*(\{.*?\})\s*,\s*"template"', re.S)
_OPTION_PRICES_RE = re.compile(r'"optionPrices"\s*:\s*(\{.*?\})\s*,\s*"priceFormat"', re.S)


def size_label(quantity_ml: int) -> str:
    """A human size for a variant listing's name: 1500 -> "1.5L", 750 -> "75cl"."""
    if quantity_ml >= 1000:
        return f"{quantity_ml / 1000:g}L"
    return f"{quantity_ml / 10:g}cl"


def variant_prices(html: str) -> dict[int, tuple[float, float | None]]:
    """Map each size (in ml) offered on a product page to its (price, was_price).

    Magento publishes a configurable product's variants as a size attribute
    whose options name each size, plus a price block keyed by variant id.
    """
    attrs_match, prices_match = _ATTRIBUTES_RE.search(html), _OPTION_PRICES_RE.search(html)
    if not (attrs_match and prices_match):
        return {}
    try:
        attributes = json.loads(attrs_match.group(1))
        option_prices = json.loads(prices_match.group(1))
    except json.JSONDecodeError:
        return {}

    out: dict[int, tuple[float, float | None]] = {}
    for attribute in attributes.values():
        options = attribute.get("options")
        if isinstance(options, dict):
            options = list(options.values())
        for option in options or []:
            quantity_ml = parse_quantity_ml(str(option.get("label") or ""))
            if not quantity_ml:
                continue
            for variant_id in option.get("products") or []:
                block = option_prices.get(str(variant_id)) or {}
                final = (block.get("finalPrice") or {}).get("amount")
                old = (block.get("oldPrice") or {}).get("amount")
                if final is None:
                    continue
                price = round(float(final), 2)
                was = round(float(old), 2) if old and float(old) > price else None
                out[quantity_ml] = (price, was)
    return out


def _text(raw: str) -> str:
    return unescape(_TAG_RE.sub("", raw)).replace("\xa0", " ").strip()


# ProductVariant page (the "PDP"): the main offer is schema.org microdata inside
# product-info-main. The related-product_variants rail below it is made of grid
# tiles, so the page is cut at the first tile before anything is read.
_MAIN_BLOCK_RE = re.compile(r'class="product-info-main".*', re.S)
_PDP_NAME_RE = re.compile(r'itemprop="name"[^>]*>\s*(.*?)\s*<', re.S)
_PDP_META_RE = {
    key: re.compile(rf'itemprop="{key}"\s+content="([^"]*)"')
    for key in ("price", "priceCurrency", "availability", "brand", "url")
}
_PDP_SKU_RE = re.compile(r'<form[^>]*data-product-sku="([^"]+)"')
# Fallback when the form is absent: the price box's own product id is not a
# SKU, so only the form attribute counts.
_TILE_START_RE = re.compile(r'class="product-item-info')


def parse_product_page(page: str) -> dict | None:
    """The main offer of one product page, or None when it has none.

    Read from Magento's offer microdata (price, currency, availability) with
    the typed finalPrice span as the price fallback, never from the first
    price on the page: the related-products rail is a grid of tiles and its
    first tile is a different bottle.
    """
    main = _MAIN_BLOCK_RE.search(page)
    if not main:
        return None
    block = main.group(0)
    tile = _TILE_START_RE.search(block)
    if tile:
        block = block[: tile.start()]
    sku = _PDP_SKU_RE.search(block)
    name = _PDP_NAME_RE.search(block)
    meta = {k: (m.search(block).group(1) if m.search(block) else None) for k, m in _PDP_META_RE.items()}
    price = float(meta["price"]) if meta["price"] else _typed_price(_FINAL_PRICE_RE, block)
    if not (sku and name) or price is None or price <= 0:
        return None
    old = _typed_price(_OLD_PRICE_RE, block)
    availability = meta["availability"] or ""
    return {
        "sku": sku.group(1),
        # Magento writes "Brand - Name" in the title; the grid writes them apart.
        "name": _text(name.group(1)).replace(" - ", " ", 1),
        "brand": unescape(meta["brand"]) if meta["brand"] else None,
        "price": round(price, 2),
        "was_price": round(old, 2) if old and old > price else None,
        "currency": meta["priceCurrency"] or None,
        "in_stock": ("InStock" in availability) if availability else None,
        "url": meta["url"],
    }


@dataclass(frozen=True, slots=True)
class AvoltaStore:
    code: str
    iata: str
    name: str
    city: str
    country: str
    currency: str
    base_url: str


def parse_product_variants(page: str) -> list[dict]:
    """Products from one category page. Kept separate so it is testable offline."""
    out: list[dict] = []
    for chunk in _ITEM_SPLIT.split(page)[1:]:
        url = _URL_RE.search(chunk)
        name = _NAME_RE.search(chunk)
        sku = _SKU_RE.search(chunk)
        final = _typed_price(_FINAL_PRICE_RE, chunk)
        if final is None:
            fallback = _PRICE_RE.search(chunk)
            final = float(fallback.group(1)) if fallback else None
        if not (url and name and sku) or final is None:
            continue
        old = _typed_price(_OLD_PRICE_RE, chunk)
        brand = _BRAND_RE.search(chunk)
        out.append(
            {
                "sku": sku.group(1),
                "name": _text(name.group(1)),
                "brand": _text(brand.group(1)) if brand else None,
                "price": round(final, 2),
                "was_price": round(old, 2) if old and old > final else None,
                "url": url.group(1),
                # The tile as served, so the parse can be re-run when a rule
                # changes. Everything above was derived from it.
                "html": chunk,
            }
        )
    return out


class AvoltaCollector(Collector):
    """One Avolta storefront."""

    parser_version = PARSER_VERSION
    # Ingest hands over the store's held listings before each collect(); the
    # page-1-only stores re-read the URLs the walk did not reach.
    wants_held_listings = True

    def __init__(self, store: AvoltaStore) -> None:
        self.store = store
        self.held_listings: list = []
        self.slug = f"avolta-{store.code.lower()}"
        self.retailer_slug = "avolta"
        self.retailer_name = "Avolta"
        self.operator = "Avolta"
        self.homepage = store.base_url

    def shops(self) -> list[ShopSpec]:
        return [
            ShopSpec(
                code=self.store.code,
                iata=self.store.iata,
                name=self.store.name,
                city=self.store.city,
                country=self.store.country,
                currency=self.store.currency,
            )
        ]

    def _category_urls(self, page: str, robots: Robots, vertical: str = "liquor") -> list[str]:
        """One tree's categories, one level below its root, capped with a logged drop."""
        found = sorted(
            u for u in set(_LINK_RE[vertical].findall(page)) if not _NOT_A_CATEGORY.search(u)
        )
        allowed = [u for u in found if robots.allows(u)]
        # Prefer one level below the tree's root (e.g. /liquor/whisky) over
        # deeper leaves, which are narrow and cost a request each.
        shallow = [u for u in allowed if u.rstrip("/").count("/") <= 6]
        urls = shallow or allowed
        # "view-all" duplicates every other category; only worth walking when it
        # is the sole listing the storefront offers.
        real = [u for u in urls if "view-all" not in u and "digital" not in u]
        urls = real or urls
        if len(urls) > MAX_CATEGORIES_PER_VERTICAL:
            logger.warning(
                "categories_capped store=%s vertical=%s cap=%d dropped=%s",
                self.store.code, vertical, MAX_CATEGORIES_PER_VERTICAL,
                [u.rsplit("/", 1)[-1] for u in urls[MAX_CATEGORIES_PER_VERTICAL:]],
            )
        return urls[:MAX_CATEGORIES_PER_VERTICAL]

    def _listing(self, item: dict, currency: str, category_url: str | None,
                 price: float, was_price: float | None,
                 variant: dict | None = None, vertical: str | None = None) -> RawListing:
        category_url = category_url or ""
        full_name = (
            f"{item['brand']} {item['name']}"
            if item["brand"] and not item["name"].lower().startswith(item["brand"].lower())
            else item["name"]
        )
        return RawListing(
            source_sku=item["sku"],
            name=full_name,
            brand=item["brand"],
            # A per-size variant listing ("sku::1500") must never mint a
            # barcode from its parent's digits -- each size has its own EAN,
            # which the grid does not publish. Identity comes from name+size.
            gtin=None if "::" in item["sku"] else gtin_from_sku(item["sku"]),
            price=price,
            was_price=was_price,
            currency=currency,
            shop_code=self.store.code,
            # Tile names on this platform often omit the size the URL slug
            # states; without it, identical bottles cannot merge across shops.
            quantity_ml=parse_quantity_ml(full_name) or parse_quantity_ml_from_url(item["url"]),
            abv=parse_abv(full_name),
            feed_categories=[
                category_url.rstrip("/").split("/")[-1].replace("-", " ").title()
            ] if category_url else [],
            url=item["url"],
            in_stock=item.get("in_stock"),
            # The tile's parsed fields, never its markup. `tile_html` used to carry the whole
            # rendered tile: 24 KB a row, 1.4 MB at worst, 124 MB in all and 91% of every
            # fragment we had kept, with an `<img>` and its alt text in 98% of them. Nothing
            # ever read it -- two readers dropped it as "the one bulky key" and the third
            # fetched it and threw it away -- and the only facts in it, the sku and the price,
            # are parsed into `tile` and onto the listing. Storing a retailer's markup and
            # imagery is the thing agents.md's "collect facts, not expression" exists to stop.
            raw=facts_only({
                "tile": {k: v for k, v in item.items() if k != "html"},
                "variant": variant,
                "category_url": category_url,
            }),
            # The tree that was walked names the family; a held-URL re-read
            # passes the product's own, because it has no category path.
            vertical=vertical or vertical_from_hints(category_url or item["url"]) or "liquor",
        )

    def listing_from_product_page(self, listing, page: str) -> RawListing | None:
        """What one product page says about one published listing, offline."""
        ref = listing_ref(listing)
        url = ref.url or ""
        parent, _, size = ref.source_sku.partition("::")
        if size:
            # A per-size row: the page prices every size; take this one.
            variants = variant_prices(page)
            priced = variants.get(int(size))
            header = parse_product_page(page)
            if priced is None or header is None:
                return None
            price, was = priced
            base_name = header["name"]
            item = {
                "sku": ref.source_sku,
                "name": f"{base_name} {size_label(int(size))}",
                "brand": header["brand"],
                "price": price,
                "was_price": was,
                "url": url,
                "in_stock": header["in_stock"],
                "html": None,
            }
            return self._listing(
                item, header["currency"] or self.store.currency, None, price, was,
                variant={"size_ml": int(size), "price": price, "was_price": was},
                vertical=ref.vertical,
            )
        header = parse_product_page(page)
        if header is None or header["sku"] != ref.source_sku:
            # A different SKU means the URL now serves another product
            # (a redirect after delisting); that is "gone", not "moved".
            return None
        item = {**header, "url": url or header["url"] or "", "html": None}
        return self._listing(
            item, header["currency"] or declared_currency(page) or self.store.currency,
            None, header["price"], header["was_price"], vertical=ref.vertical,
        )

    def listings_from_product_page(self, url: str, page: str, vertical: str | None) -> list[RawListing]:
        """Every priced row one product page offers: one for a plain SKU, one per
        size for a configurable, offline. Used by the held-URL re-read, where the
        page is fetched once whatever the database holds for it."""
        header = parse_product_page(page)
        if header is None:
            return []
        currency = header["currency"] or declared_currency(page) or self.store.currency
        variants = variant_prices(page)
        if header["sku"].endswith(_CONFIGURABLE_SUFFIX) and not variants:
            return []
        if not variants:
            item = {**header, "url": url or header["url"] or "", "html": None}
            return [self._listing(item, currency, None, header["price"], header["was_price"],
                                  vertical=vertical)]
        out = []
        for quantity_ml, (price, was) in sorted(variants.items()):
            item = {
                "sku": f"{header['sku']}::{quantity_ml}", "name": f"{header['name']} {size_label(quantity_ml)}",
                "brand": header["brand"], "price": price, "was_price": was,
                "url": url or header["url"] or "", "in_stock": header["in_stock"], "html": None,
            }
            out.append(self._listing(
                item, currency, None, price, was,
                variant={"size_ml": quantity_ml, "price": price, "was_price": was}, vertical=vertical,
            ))
        return out

    def read_one(self, listing) -> RawListing | None:
        ref = listing_ref(listing)
        if not ref.url:
            return None
        robots = check_allowed(self.store.base_url, ["/en/"], fresh=False)
        if not robots.allows(ref.url):
            raise SourceBlocked(f"{self.store.code}: robots.txt disallows {ref.url}")
        try:
            page = fetch(ref.url, accept="text/html", delay=robots.delay_for(1.0))
        except FetchError as exc:
            if gone(exc):
                return None
            raise
        return self.listing_from_product_page(ref, page.text)

    def collect(self, *, limit: int | None = None, delay: float = 1.0) -> Iterator[RawListing]:
        # Each host's own robots.txt, every run: the delay it asks for (30 s at
        # some airports, 60 at others) and the paths it withholds. Four stores
        # forbid any query string, which makes them page-1-only below.
        robots = check_allowed(self.store.base_url, ["/en/"])
        crawl_delay = robots.delay_for(delay)
        targets = load_beauty_targets()

        home = fetch(f"{self.store.base_url}/en/", accept="text/html", delay=0.0)
        trees = {v: self._category_urls(home.text, robots, v) for v in VERTICAL_SLUGS}
        if not trees["liquor"]:
            raise FetchError(
                f"{self.store.code}: no drinks categories found on the storefront home page"
            )
        if not trees["beauty"]:
            logger.info("beauty_tree_absent store=%s", self.store.code)
        page_one_only = not robots.allows(f"{trees['liquor'][0]}?p=2")
        if page_one_only:
            logger.info("page_one_only store=%s reason=robots forbids query strings", self.store.code)

        produced = 0
        pages_read = 0
        seen: set[str] = set()
        # Multi-size tiles are held back: their listed price is the cheapest
        # variant's, so each needs its product page read before it is trusted.
        # Two queues, so the targeted beauty lines never wait behind drinks.
        deferred: dict[str, list[tuple[dict, str, str]]] = {"liquor": [], "beauty": []}

        for vertical, categories in trees.items():
            # Beauty: page 1 only, everywhere, until the targeted set has been
            # priced (Decision 4); drinks page until a page adds nothing new.
            max_pages = 1 if vertical == "beauty" else MAX_PAGES_PER_CATEGORY
            for category_url in categories:
                for page_number in range(1, max_pages + 1):
                    page_url = (
                        category_url if page_number == 1 else f"{category_url}?p={page_number}"
                    )
                    if not robots.allows(page_url):
                        break
                    try:
                        page = fetch(page_url, accept="text/html", delay=crawl_delay)
                    except FetchError:
                        # Category listings drift; a dead link on one page must not
                        # end the run for a whole airport.
                        break
                    pages_read += 1
                    symbol = _SYMBOL_RE.search(page.text)
                    currency = declared_currency(page.text) or (
                        SYMBOL_CURRENCIES.get(symbol.group(1).strip(), self.store.currency)
                        if symbol
                        else self.store.currency
                    )
                    new_on_page = 0
                    for item in parse_product_variants(page.text):
                        if item["sku"] in seen:
                            continue
                        if item["price"] <= 0:
                            report_skip("no_price", source_sku=item["sku"], url=item["url"])
                            continue
                        seen.add(item["sku"])
                        new_on_page += 1
                        if item["sku"].endswith(_CONFIGURABLE_SUFFIX):
                            # A beauty family costs a product-page fetch; only a
                            # targeted line earns one. Simple tiles are free.
                            if vertical == "beauty" and not targets.matches(item["name"], item["brand"]):
                                continue
                            deferred[vertical].append((item, currency, category_url))
                            continue
                        yield self._listing(
                            item, currency, category_url, item["price"], item.get("was_price"),
                            vertical=vertical,
                        )
                        produced += 1
                        if limit and produced >= limit:
                            return
                    # A page that adds nothing new is the end of the category --
                    # Magento serves the last page again past the end.
                    if new_on_page == 0:
                        break

        if produced == 0 and not any(deferred.values()):
            # Not every storefront on this platform server-renders its product
            # grid; some build it in the browser. Those cannot be read from the
            # HTML, and we do not chase them into paths their robots disallows.
            raise FetchError(
                f"{self.store.code}: read {pages_read} category page(s) but found no "
                "products in the HTML (this storefront renders its grid client-side)"
            )

        for vertical, cap in (("liquor", MAX_VARIANT_LOOKUPS), ("beauty", MAX_BEAUTY_LOOKUPS)):
            queue = deferred[vertical]
            if len(queue) > cap:
                logger.warning(
                    "variant_lookups_capped store=%s vertical=%s wanted=%d cap=%d dropped=%s",
                    self.store.code, vertical, len(queue), cap,
                    [item["sku"] for item, _, _ in queue[cap:]],
                )
            for item, currency, category_url in queue[:cap]:
                # A multi-size tile is a FAMILY of product_variants, and which size the
                # tile's own price belongs to differs per store (Zurich defaults
                # the Armand de Brignac tile to the 1.5L, JFK to the 75cl --
                # comparing tiles compared different bottles). The product page
                # prices each size; emit each one as its own listing.
                variants: dict[int, tuple[float, float | None]] = {}
                if robots.allows(item["url"]):
                    try:
                        detail = fetch(item["url"], accept="text/html", delay=crawl_delay)
                        variants = variant_prices(detail.text)
                    except FetchError:
                        variants = {}
                if not variants:
                    # Unreadable page: the tile price exists but we cannot know
                    # which size it belongs to. Skipping is the honest option.
                    logger.info(
                        "variant_page_unreadable store=%s sku=%s", self.store.code, item["sku"]
                    )
                    report_skip("variant_page_unreadable", source_sku=item["sku"], url=item["url"])
                    continue
                for quantity_ml, (price, was_price) in sorted(variants.items()):
                    variant_item = {
                        **item,
                        "sku": f"{item['sku']}::{quantity_ml}",
                        "name": f"{item['name']} {size_label(quantity_ml)}",
                    }
                    seen.add(variant_item["sku"])
                    yield self._listing(
                        variant_item, currency, category_url, price, was_price,
                        variant={"size_ml": quantity_ml, "price": price, "was_price": was_price},
                        vertical=vertical,
                    )
                    produced += 1
                    if limit and produced >= limit:
                        return

        if page_one_only:
            for row in self._reread_held(robots, crawl_delay, seen):
                yield row
                produced += 1
                if limit and produced >= limit:
                    return

    def _reread_held(self, robots: Robots, crawl_delay: float, seen: set[str]) -> Iterator[RawListing]:
        """Product URLs the database holds for this store that the walk did not
        reach: at a page-1-only store they are the page-2+ products, and each
        page is read once whatever the number of size rows behind it. Bounded
        because every read costs the host's crawl delay; the log says what the
        bound left unread so the shrink is measurable."""
        pending: dict[str, str | None] = {}
        for ref in self.held_listings:
            ref = listing_ref(ref)
            parent = ref.source_sku.partition("::")[0]
            if not ref.url or ref.source_sku in seen or parent in seen or not robots.allows(ref.url):
                continue
            pending.setdefault(ref.url, ref.vertical)
        urls = list(pending)
        logger.info(
            "held_reread store=%s held=%d unseen_urls=%d reading=%d",
            self.store.code, len(self.held_listings), len(urls), min(len(urls), MAX_HELD_REREADS),
        )
        if len(urls) > MAX_HELD_REREADS:
            logger.warning(
                "held_reread_capped store=%s cap=%d unread=%d",
                self.store.code, MAX_HELD_REREADS, len(urls) - MAX_HELD_REREADS,
            )
        for url in urls[:MAX_HELD_REREADS]:
            try:
                page = fetch(url, accept="text/html", delay=crawl_delay)
            except FetchError as exc:
                if not gone(exc):
                    report_skip("reread_failed", url=url, detail=str(exc)[:200])
                continue
            for row in self.listings_from_product_page(url, page.text, pending[url]):
                if row.source_sku in seen:
                    continue
                seen.add(row.source_sku)
                yield row


# A curated set of major airports across every region Avolta operates in.
# The platform has 168; these are the ones worth showing first.
STORES: tuple[AvoltaStore, ...] = (
    AvoltaStore("LHR", "LHR", "London Heathrow", "London", "United Kingdom", "GBP",
                "https://london-heathrow.worlddutyfree.com"),
    AvoltaStore("ATH", "ATH", "Athens International", "Athens", "Greece", "EUR",
                "https://athens.shopdutyfree.com"),
    AvoltaStore("MAD", "MAD", "Madrid Barajas", "Madrid", "Spain", "EUR",
                "https://madrid.shopdutyfree.com"),
    AvoltaStore("BCN", "BCN", "Barcelona El Prat", "Barcelona", "Spain", "EUR",
                "https://barcelona.shopdutyfree.com"),
    AvoltaStore("ZRH", "ZRH", "Zurich", "Zurich", "Switzerland", "CHF",
                "https://zurich.shopdutyfree.com"),
    AvoltaStore("HKG", "HKG", "Hong Kong International", "Hong Kong", "Hong Kong", "HKD",
                "https://hongkong.shopdutyfree.com"),
    AvoltaStore("YYZ", "YYZ", "Toronto Pearson", "Toronto", "Canada", "CAD",
                "https://toronto.shopdutyfree.com"),
    # Quotes in USD with the peso shown alongside; the microdata confirms it.
    AvoltaStore("MEX", "MEX", "Mexico City Benito Juarez", "Mexico City", "Mexico", "USD",
                "https://mexicocity.shopdutyfree.com"),
    # Both named in the client's original brief as the journey he wants priced.
    AvoltaStore("JFK", "JFK", "New York JFK", "New York", "United States", "USD",
                "https://jfk.shopdutyfree.com"),
    AvoltaStore("EZE", "EZE", "Buenos Aires Ezeiza", "Buenos Aires", "Argentina", "USD",
                "https://buenosaires.shopdutyfree.com"),
)


def avolta_collectors() -> list[AvoltaCollector]:
    return [AvoltaCollector(store) for store in STORES]
