"""iShopChangi, the online duty-free marketplace of Singapore Changi (Changi Airport Group).

The first source read through the rendered fetch, and the reasons are all
measured (2026-09-04/05, `.logs/runs/changi-probe-2026-09-05.log`):

* **The page is a shell.** A product page as served is 62 KB of markup and no
  text; the React bundle then fetches the data with a token it keeps in the
  browser's own storage. We never mint or replay that token. The browser
  sidecar loads a permitted product page, the page fetches its own
  `pdp/inventory.json`, and we read that response as the page received it.
* **The storefront bundle needs its tag manager to boot.** Without the Adobe
  Launch script it dereferences an undefined global and draws nothing, so that
  one host is declared as an asset host for this source. The analytics beacon
  hosts it then calls are not declared and stay aborted.
* **Prices are per collection channel.** The same offer is S$271.10 at
  Departure and Arrival (GST-free) and S$333.87 for a non-traveller home
  delivery. The default render shows Departure, which is the duty-free price a
  traveller pays; that is the tier we publish, named in `price_type`.
* **There is no barcode anywhere** in the page, the inventory payload or the
  search tiles, so every product here matches others only on brand, name and
  size. Weighed and accepted for the targeted set (build plan §7); the
  catalogue-wide crawl is a separate decision.
* **Targeted first.** The product sitemap lists about thirty thousand URLs;
  the collector renders only those in `import/changi-targets.json` (seeded
  from drinks stocked at two or more of the collected shops and the beauty
  brands shared by Paris and Athens) unless constructed with
  `full_catalogue=True`, which needs rian's crawl token.

Location `SIN`, currency `SGD`. Sizes come from the sale-measure qualifier
and the name; alcohol from the classification the page shows.
"""

import json
import logging
import pathlib
import re
from collections.abc import Iterator

from app.services.collectors.base import (
    Collector,
    LocationSpec,
    RawListing,
    facts_only,
    gone,
    listing_ref,
    report_skip,
)
from app.services.collectors.fetch import FetchError, SourceBlocked, fetch, render, render_budget
from app.services.collectors.robots import check_allowed
from app.services.normalize import looks_exclusive, parse_abv, parse_size_ml
from app.services.quantity import Quantity
from app.services.taxonomy import vertical_from_hints

logger = logging.getLogger(__name__)

PARSER_VERSION = "changi/2026-09-05"

BASE = "https://www.ishopchangi.com"
PRODUCT_SITEMAP = "/en/sitemap-products.xml"
PRODUCT_PATH = "/en/product/"
# The storefront's own bootstrap dependency (see module docstring). Nothing else.
ASSET_HOSTS = ("assets.adobedtm.com",)
# The element the page draws once its data arrived; waited for, never clicked.
WAIT_FOR = "[class*=price], [class*=Price]"
SETTLE_MS = 4000
# The duty-free tier a departing traveller pays; the default the page shows.
PRICE_CHANNEL = "DEPARTURE"
PRICE_TYPE = "departure"
# One render is roughly fifty text fetches; a run states its cap up front.
RENDER_CAP = 800
TARGETS_FILES = (
    pathlib.Path("/srv/import/changi-targets.json"),
    pathlib.Path(__file__).resolve().parents[4] / "import" / "changi-targets.json",
)

_PRODUCT_URL_RE = re.compile(r"^https://www\.ishopchangi\.com/en/product/[a-z0-9-]+-(mp\d+)$")
_LOC_RE = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>", re.I)
_SGD_RE = re.compile(r"^\s*S\$")


def product_urls(xml: str) -> list[str]:
    """Product URLs from the product sitemap, in file order, de-duplicated."""
    seen: set[str] = set()
    out: list[str] = []
    for loc in (m.strip() for m in _LOC_RE.findall(xml)):
        if _PRODUCT_URL_RE.match(loc) and loc not in seen:
            seen.add(loc)
            out.append(loc)
    return out


def product_code(url: str) -> str | None:
    """The trailing mp-code is the product identity ("mp00089136")."""
    match = _PRODUCT_URL_RE.match(url.split("?", 1)[0].removesuffix(".html"))
    return match.group(1) if match else None


def load_targets(path: pathlib.Path | None = None) -> list[str]:
    """The URLs the targeted run may render. Absent file means render nothing."""
    candidates = [path] if path else list(TARGETS_FILES)
    for candidate in candidates:
        if candidate and candidate.is_file():
            data = json.loads(candidate.read_text())
            urls = data.get("urls") if isinstance(data, dict) else data
            return [u for u in urls or [] if isinstance(u, str)]
    return []


def channel_price(offer: dict, channel: str = PRICE_CHANNEL) -> tuple[float | None, float | None]:
    """The price and crossed-out price of one collection channel, in SGD.

    The API labels its SGD figures "en" and its CNY figures "zh"; the string
    form carries the symbol, and only a channel whose price reads "S$..." is
    accepted. A channel the offer does not list is None, never another
    channel's figure: the non-traveller price is a fifth higher and would
    flatter every other shop.
    """
    for entry in offer.get("channelPrices") or []:
        if not isinstance(entry, dict) or entry.get("channelCode") != channel:
            continue
        if not _SGD_RE.match(str(entry.get("enDiscountedPrice") or "")):
            return None, None
        try:
            price = float(entry["enDiscountedValue"])
        except (KeyError, TypeError, ValueError):
            return None, None
        try:
            was = float(entry.get("enOriginalValue"))
        except (TypeError, ValueError):
            was = None
        return price, (was if was and was > price else None)
    return None, None


def size_ml_of(variant: dict, name: str) -> int | None:
    """Millilitres from the name first, else the sale-measure qualifier.

    The name states the size in the retailer's own words ("1000ML"); the
    qualifier is a bare number whose unit is not declared, so it is trusted
    only when the sale measure is a volume and the name says nothing.
    """
    from_name = parse_size_ml(name)
    if from_name:
        return from_name
    if str(variant.get("saleMeasureType") or "").lower() != "volume":
        return None
    for qualifier in variant.get("variantOptionQualifiers") or []:
        if isinstance(qualifier, dict) and qualifier.get("qualifier") == "level1saleMeasure":
            try:
                value = float(qualifier.get("value"))
            except (TypeError, ValueError):
                return None
            return int(value) if 20 <= value <= 20_000 else None
    return None


def quantity_of_variant(variant: dict) -> tuple[Quantity | None, str | None]:
    """The variant's own sale-measure qualifier as a Quantity: millilitres for a volume
    measure, grams for a weight measure. A measure type we do not key on still reports the
    bare value as text (never silence), and no figure at all is (None, None)."""
    value = None
    for qualifier in variant.get("variantOptionQualifiers") or []:
        if isinstance(qualifier, dict) and qualifier.get("qualifier") == "level1saleMeasure":
            try:
                value = float(qualifier.get("value"))
            except (TypeError, ValueError):
                value = None
            break
    if value is None:
        return None, None
    measure = str(variant.get("saleMeasureType") or "").strip().lower()
    if measure == "volume":
        return Quantity(value=value, unit="ml", form="single", state="stated"), f"{value:g} ml"
    if measure == "weight":
        return Quantity(value=value, unit="g", form="single", state="stated"), f"{value:g} g"
    return None, f"{value:g}"


def abv_of(product: dict, name: str) -> float | None:
    for classification in product.get("productClassifications") or []:
        if isinstance(classification, dict) and classification.get("code") == "alcohol_percentage":
            return parse_abv(f"{classification.get('value')}%") or parse_abv(name)
    return parse_abv(name)


def in_stock_of(offer: dict) -> bool | None:
    if offer.get("outOfStock") is True:
        return False
    status = str(offer.get("stockStatus") or "").lower()
    if status:
        return status == "instock"
    quantity = offer.get("quantity")
    return quantity > 0 if isinstance(quantity, int | float) else None


def _selected_offer(variant: dict) -> dict | None:
    offers = [o for o in variant.get("offers") or [] if isinstance(o, dict)]
    if not offers:
        return None
    return next((o for o in offers if o.get("selected")), offers[0])


def listings_from_inventory(payload: dict, url: str) -> list[RawListing]:
    """RawListings from the page's own `pdp/inventory.json` response.

    One listing per variant (each size is its own row); the price is the
    Departure channel of the variant's selected offer. A marketplace offer
    names its seller, kept in the raw record because the shopper sees the
    marketplace, not the seller.
    """
    product = payload.get("product") if isinstance(payload, dict) else None
    if not isinstance(product, dict):
        return []
    name = str(product.get("name") or "").strip()
    code = str(product.get("code") or "").strip()
    if not name or not code:
        return []
    brand = (product.get("brandName") or None) and str(product["brandName"]).strip()
    crumbs = [c.get("name") for c in product.get("breadcrumb") or [] if isinstance(c, dict) and c.get("name")]
    categories = list(reversed(crumbs))  # the API lists leaf first
    vertical = vertical_from_hints(*categories) if categories else None
    abv = abv_of(product, name)
    variants = [v for v in product.get("variantOptions") or [] if isinstance(v, dict)]
    raw_common = facts_only({k: v for k, v in product.items() if k != "variantOptions"})

    out: list[RawListing] = []
    multi = len(variants) > 1
    for variant in variants:
        offer = _selected_offer(variant)
        sku = str(variant.get("code") or "").strip()
        if offer is None or not sku:
            report_skip("no_offer", source_sku=sku or code, url=url)
            continue
        price, was_price = channel_price(offer)
        if price is None:
            report_skip("no_channel_price", source_sku=sku, url=url, channel=PRICE_CHANNEL)
            continue
        size_ml = size_ml_of(variant, name)
        quantity, quantity_text = quantity_of_variant(variant)
        full_name = name
        if multi and size_ml and not parse_size_ml(name):
            full_name = f"{name} {size_ml}ml"
        out.append(
            RawListing(
                source_sku=sku,
                name=full_name,
                brand=brand,
                gtin=None,  # the source publishes none anywhere
                price=price,
                was_price=was_price,
                currency="SGD",
                location_code="SIN",
                in_stock=in_stock_of(offer),
                size_ml=size_ml,
                quantity=quantity,
                quantity_text=quantity_text,
                abv=abv,
                feed_categories=categories,
                url=url,
                price_type=PRICE_TYPE,
                is_exclusive=looks_exclusive(name),
                raw={
                    **raw_common,
                    "variant": facts_only({k: v for k, v in variant.items() if k != "offers"}),
                    "offer": facts_only(offer),
                },
                vertical=vertical,
            )
        )
    return out


class Changi(Collector):
    slug = "ishopchangi-sin"
    retailer_slug = "ishopchangi"
    retailer_name = "iShopChangi"
    operator = "Changi Airport Group"
    homepage = BASE
    parser_version = PARSER_VERSION
    # The source's own pace between renders, over the run's delay and the general
    # ten-second floor (fetch.render_wait). None keeps the general floor. The
    # first run (5 Sep) drew 18 pages cleanly at that pace and was answered 403 on
    # the 19th; whether to retry once at thirty seconds is rian's
    # (decide-singapore-answered-403...): on a yes this becomes 30.0, and until
    # then a single run can still be slowed with `collect --delay 30`.
    render_floor_seconds: float | None = None

    def __init__(self, *, full_catalogue: bool = False, targets_file: pathlib.Path | None = None) -> None:
        self.full_catalogue = full_catalogue
        self.targets_file = targets_file

    def locations(self) -> list[LocationSpec]:
        return [
            LocationSpec(
                code="SIN",
                iata="SIN",
                name="Singapore Changi",
                city="Singapore",
                country="Singapore",
                currency="SGD",
            )
        ]

    def _render_listings(self, url: str, robots, delay: float) -> list[RawListing]:
        page = render(
            url,
            delay=delay,
            floor=self.render_floor_seconds,
            robots=robots,
            allow_hosts=ASSET_HOSTS,
            wait_for=WAIT_FOR,
            settle_ms=SETTLE_MS,
        )
        payloads = list(page.api_json("pdp/inventory.json"))
        if not payloads:
            report_skip("no_inventory_payload", url=url, text_length=page.text_length)
            return []
        return listings_from_inventory(payloads[0], url)

    def collect(self, *, limit: int | None = None, delay: float = 10.0) -> Iterator[RawListing]:
        robots = check_allowed(BASE, [PRODUCT_SITEMAP, PRODUCT_PATH])
        xml = fetch(f"{BASE}{PRODUCT_SITEMAP}", accept="application/xml", delay=0).text
        catalogue = product_urls(xml)
        if self.full_catalogue:
            urls = catalogue
        else:
            wanted = set(load_targets(self.targets_file))
            urls = [u for u in catalogue if u in wanted]
            logger.info(
                "changi_targets catalogue=%d targets=%d in_sitemap=%d",
                len(catalogue), len(wanted), len(urls),
            )
        if limit is not None:
            urls = urls[:limit]

        produced = 0
        seen_codes: set[str] = set()
        with render_budget(min(len(urls), RENDER_CAP), label=self.slug):
            for url in urls:
                code = product_code(url)
                if code in seen_codes:
                    continue
                seen_codes.add(code or url)
                try:
                    listings = self._render_listings(url, robots, delay)
                except SourceBlocked:
                    raise
                except FetchError as exc:
                    logger.warning("changi_render_failed url=%s error=%s", url, exc)
                    continue
                for listing in listings:
                    yield listing
                    produced += 1
                    if limit is not None and produced >= limit:
                        return

    def read_one(self, listing) -> RawListing | None:
        ref = listing_ref(listing)
        if not ref.url:
            return None
        robots = check_allowed(BASE, [PRODUCT_PATH], fresh=False)
        try:
            rows = self._render_listings(ref.url, robots, 10.0)
        except FetchError as exc:
            if gone(exc):
                return None
            raise
        return next((row for row in rows if row.source_sku == ref.source_sku), None)
