"""Dubai Duty Free.

The largest single-airport duty-free operation in the world, and it publishes
its whole catalogue over an open, unauthenticated store API. Its robots.txt
permits everything except cart, checkout and account paths, so this is a front
door, not a workaround.
"""

import logging
from collections.abc import Iterator
from urllib.parse import urlencode

from app.services.collectors.base import Collector, ShopSpec, RawListing, facts_only, report_skip
from app.services.collectors.fetch import PageGone, SourceBlocked, fetch_json
from app.services.collectors.robots import check_allowed
from app.services.normalize import clean_gtin, parse_abv, parse_quantity_ml

PARSER_VERSION = "dubai/2026-09-04"

logger = logging.getLogger(__name__)

BASE = "https://www.dubaidutyfree.com"
PRODUCTS_PATH = "/ccstore/v1/products"
COLLECTIONS_PATH = "/ccstore/v1/collections/"
PAGE_SIZE = 250
LIQUOR_CATEGORY_ID = "107"

# The store API has no server-side category filter, so we page and filter. The
# catalogue is ordered by product id with liquor at the front, so once several
# consecutive pages contain no liquor we have passed the end of the section and
# should stop rather than walk the remaining tens of thousands of product_variants.
EMPTY_PAGES_BEFORE_STOP = 4
MAX_PAGES = 60


# Fields the store API would carry a real stock signal in. `active` is not
# one: it says the product is listed, and every one of 7,445 observations
# said "in stock" on the strength of it.
_STOCK_FIELDS = ("stockStatus", "x_stockStatus", "inStock", "orderableQuantity", "x_inStock")


def in_stock_of(item: dict) -> bool | None:
    """What the API says about stock, and None when it says nothing."""
    for key in _STOCK_FIELDS:
        value = item.get(key)
        if value is None:
            continue
        if isinstance(value, bool):
            return value
        if isinstance(value, int | float):
            return value > 0
        text = str(value).strip().upper()
        if text in {"IN_STOCK", "INSTOCK", "TRUE", "YES"}:
            return True
        if text in {"OUT_OF_STOCK", "OUTOFSTOCK", "FALSE", "NO"}:
            return False
    return None


def category_hints(item: dict, names: dict[str, str]) -> list[str]:
    """Category labels for a product, from the ids on its category paths."""
    hints: list[str] = []
    raw_paths = item.get("parentCategoryIdPath") or ""
    for path in (raw_paths if isinstance(raw_paths, list) else [raw_paths]):
        for part in str(path).split(">"):
            label = names.get(part.strip())
            if label and label not in hints:
                hints.append(label)
    fallback = (item.get("type") or "").title()
    if fallback and fallback not in hints:
        hints.append(fallback)
    return hints


class DubaiDutyFree(Collector):
    slug = "dubai-duty-free"
    retailer_slug = "dubai-duty-free"
    retailer_name = "Dubai Duty Free"
    operator = "Dubai Duty Free"
    homepage = "https://www.dubaidutyfree.com"
    parser_version = PARSER_VERSION

    def __init__(self) -> None:
        self._categories: dict[str, str] | None = None

    def shops(self) -> list[ShopSpec]:
        return [
            ShopSpec(
                code="DXB",
                iata="DXB",
                name="Dubai International",
                city="Dubai",
                country="United Arab Emirates",
                currency="AED",
            )
        ]

    def _category_names(self, delay: float) -> dict[str, str]:
        """Map the store's liquor category ids to their display names.

        Fetched once per run rather than hardcoded, so a renamed or added
        category follows automatically.
        """
        if self._categories is not None:
            return self._categories
        names: dict[str, str] = {}
        try:
            payload = fetch_json(
                f"{BASE}{COLLECTIONS_PATH}{LIQUOR_CATEGORY_ID}?expand=childCategories",
                delay=delay,
            )
            for child in (payload.get("childCategories") or []):
                repo_id, label = child.get("repositoryId"), child.get("displayName")
                if repo_id and label:
                    names[str(repo_id)] = label
        except PageGone as exc:
            # Named before the broad catch below can fold it into a silent "no names": a
            # collection endpoint that is not there is a fact worth reading in the log.
            logger.warning("dubai_page_gone source=%s detail=%s", self.slug, exc)
            names = {}
        except Exception:
            # Category names are an enrichment; product names still classify.
            names = {}
        self._categories = names
        return names

    def _page(self, offset: int, delay: float) -> list[dict]:
        query = urlencode({"limit": PAGE_SIZE, "offset": offset, "sort": "id"})
        payload = fetch_json(f"{BASE}{PRODUCTS_PATH}?{query}", delay=delay)
        return payload.get("items", []) if isinstance(payload, dict) else []

    @staticmethod
    def _is_liquor(item: dict) -> bool:
        path = item.get("parentCategoryIdPath") or ""
        if LIQUOR_CATEGORY_ID in str(path).split(">"):
            return True
        return (item.get("type") or "").upper() in {"SPIRITS", "WINE", "CHAMPAGNE", "LIQUOR"}

    def read_one(self, listing) -> RawListing | None:
        """No per-listing read: the host refuses our identity (robots.txt answers
        403), and Decision 3 makes Dubai a partnership ask, not a fetch."""
        raise SourceBlocked(f"{self.slug}: no read path while the host refuses us; see Decision 3")

    def collect(self, *, limit: int | None = None, delay: float = 1.0) -> Iterator[RawListing]:
        delay = check_allowed(BASE, [PRODUCTS_PATH, COLLECTIONS_PATH]).delay_for(delay)
        names = self._category_names(0.0)
        offset, produced, pages, empty_streak = 0, 0, 0, 0
        while pages < MAX_PAGES:
            try:
                items = self._page(offset, delay if offset else 0.0)
            except PageGone as exc:
                # A page of the product API that is not there ends the walk, by name. With
                # nothing collected we have read nothing: that is our failure, not a refusal.
                logger.warning("dubai_page_gone source=%s detail=%s", self.slug, exc)
                if produced:
                    return
                raise
            if not items:
                return
            pages += 1
            matched_on_page = 0
            for item in items:
                if not item.get("active", True) or not self._is_liquor(item):
                    continue
                price = item.get("salePrice") or item.get("listPrice")
                if price is None:
                    report_skip("no_price", source_sku=str(item.get("id")))
                    continue
                name = (item.get("displayName") or "").strip()
                if not name:
                    continue
                route = item.get("route")
                yield RawListing(
                    source_sku=str(item.get("id")),
                    name=name,
                    brand=(item.get("brand") or None),
                    gtin=clean_gtin(item.get("x_gTIN")),
                    price=float(price),
                    currency="AED",
                    was_price=float(item["listPrice"])
                    if item.get("salePrice") and item.get("listPrice")
                    else None,
                    shop_code="DXB",
                    quantity_ml=parse_quantity_ml(name),
                    abv=parse_abv(name),
                    feed_categories=category_hints(item, names),
                    is_exclusive=str(item.get("x_travelExclusive") or "").lower() == "true",
                    country_of_origin=item.get("x_countryOfOrigin") or None,
                    url=f"{BASE}{route}" if route else None,
                    in_stock=in_stock_of(item),
                    raw=facts_only(item),
                    vertical="liquor",
                )
                produced += 1
                matched_on_page += 1
                if limit and produced >= limit:
                    return
            empty_streak = 0 if matched_on_page else empty_streak + 1
            if produced and empty_streak >= EMPTY_PAGES_BEFORE_STOP:
                return
            offset += PAGE_SIZE
