"""ARI's "The Loop" — Dublin and Cork airport duty-free.

One Salesforce Commerce Cloud storefront serves both Dublin (T1 and T2) and
Cork click-and-collect from a single catalogue and price list, so this is
modelled as one location rather than two.

robots.txt disallows every category-grid query pattern (cgid, sz, start, srule,
pmin/pmax, prefn/prefv and /search), which rules out the usual grid-JSON
shortcut. Product pages and the sitemap are unrestricted, so this collector
enumerates through the product sitemap and reads each page's JSON-LD. That is
the honest cheapest path here, not a fallback of convenience.

Two passenger price tiers are shown on every drinks page without login or a
declared destination: "outside EU" (the genuine duty-free rate) and "flying
inside EU" (duty-paid, materially higher). We take the duty-free tier, because
that is the number every other retailer in this app is compared against. The
duty-paid tier is a different, EU-specific product and is not emitted.

No barcodes: this retailer publishes none, so its products match on the
brand + name + size fallback rather than on GTIN.

Two trees are walked. Drinks (`/alcohol/`) in full, as before. Beauty
(`/beauty/`, plus the other prefixes the sitemap may use for it) only for the
targeted lines (Decision 4, `collectors/targets.py`): the sitemap is read
anyway, so filtering its URLs costs nothing, and a product page is fetched only
when its slug names a line on the list. The sitemap slug here omits the brand,
so the match is on the line words and the size.
"""

import json
import logging
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
from app.services.collectors.robots import check_allowed
from app.services.collectors.targets import BeautyTargets, load_beauty_targets
from app.services.normalize import clean_gtin, parse_abv, parse_size_ml
from app.services.taxonomy import vertical_from_hints

PARSER_VERSION = "ari/2026-09-05"

logger = logging.getLogger(__name__)

# Path prefixes per family. Drinks are walked whole; beauty is targeted.
VERTICAL_PATHS: dict[str, tuple[str, ...]] = {
    "liquor": ("/alcohol/",),
    "beauty": ("/beauty/", "/fragrance/", "/skincare/", "/perfume/", "/cosmetics/"),
}

BASE = "https://www.dublinandcorkdutyfree.ie"
SITEMAP_INDEX_PATH = "/sitemap_index.xml"
SITEMAP_INDEX = f"{BASE}{SITEMAP_INDEX_PATH}"
LOCATION_CODE = "DUB"

_JSONLD_RE = re.compile(r'<script type="application/ld\+json">(.*?)</script>', re.S)
_LOC_RE = re.compile(r"<loc>(.*?)</loc>")
_PRODUCT_SITEMAP_RE = re.compile(
    r"<loc>(https://www\.dublinandcorkdutyfree\.ie/sitemap_\d+-product\.xml)</loc>"
)
_SKU_FROM_URL_RE = re.compile(r"/(\d+)\.html")
# The "was" price lives only in the rendered HTML, and only inside the
# duty-free block — the duty-paid block carries its own, differently scaled one.
_DUTYFREE_BLOCK_RE = re.compile(r"b-price__dutyfree\b.*?(?=b-price__dutypaid|\Z)", re.S)
_WAS_PRICE_RE = re.compile(r"WAS&nbsp;&euro;([\d.,]+)")
# Descriptions mix real strength ("Alcohol level: 40") with unrelated
# percentages such as grape blends, so anchor on the label rather than scanning.
_ALCOHOL_LEVEL_RE = re.compile(r"Alcohol level:\s*(\d{1,2}(?:[.,]\d)?)", re.I)
_GTIN_FIELDS = ("gtin13", "gtin", "gtin14", "gtin12", "gtin8")


def _product_sitemaps(delay: float) -> list[str]:
    page = fetch(SITEMAP_INDEX, accept="application/xml", delay=delay)
    return _PRODUCT_SITEMAP_RE.findall(page.text)


# The sitemap also lists products under a flat leaf category with no tree
# above it ("/eau-de-toilette/red-door-eau-de-toilette-100ml/000389.html"):
# 204 of the beauty products on 2026-09-05 appeared only that way.
_BEAUTY_SEGMENT_RE = re.compile(
    r"^(eau-de-|fragrance|perfume|cologne|aftershave|aftersun|skincare|body-|face|eye-|lip|"
    r"mascara|liner|foundation|concealer|blush|bronzer|brow|primer|highlighter|palette|"
    r"make-?up|day-cream|night-cream|serum|cleanser|toner|hand-care|sun-care|self-tan|"
    r"deodorant|shampoo|conditioner|hair|bath-and-shower|age-repair|refining|masks|tinted|"
    r"mens-face|mens-body|for-men|womens-|body-mist|powder-foundation|compact-foundation)"
)


def vertical_of_url(url: str) -> str | None:
    """Which tree a sitemap URL belongs to: its path prefix, else its leaf segment."""
    path = url.split("://", 1)[-1]
    path = path[path.find("/"):] if "/" in path else "/"
    for vertical, prefixes in VERTICAL_PATHS.items():
        if any(prefix in path for prefix in prefixes):
            return vertical
    first = path.strip("/").split("/", 1)[0]
    if _BEAUTY_SEGMENT_RE.match(first):
        return "beauty"
    return None


def wanted_urls(locs: list[str], targets: BeautyTargets) -> Iterator[tuple[str, str]]:
    """(url, vertical) for every sitemap URL worth a page fetch, in sitemap order.

    Every drinks URL; a beauty URL only when its slug names a targeted line.
    Kept pure so the filter is testable against a saved sitemap.
    """
    for loc in locs:
        vertical = vertical_of_url(loc)
        if vertical is None:
            continue
        if vertical == "beauty":
            # The whole path: this retailer's slug omits the brand, but a
            # category segment may carry it ("/beauty/fragrance/dior/...").
            path = loc.split("://", 1)[-1].split("?", 1)[0]
            if not targets or not targets.matches(path[path.find("/"):]):
                continue
        yield loc, vertical


def _sitemap_urls(delay: float) -> Iterator[str]:
    """Every product URL the sitemap lists, before any page is fetched."""
    first = True
    for sitemap_url in _product_sitemaps(delay):
        page = fetch(sitemap_url, accept="application/xml", delay=0.0 if first else delay)
        first = False
        yield from _LOC_RE.findall(page.text)


def _parse_product_jsonld(html_text: str) -> dict | None:
    for raw in _JSONLD_RE.findall(html_text):
        try:
            data = json.loads(raw, strict=False)
        except (json.JSONDecodeError, ValueError):
            continue
        if isinstance(data, dict) and data.get("@type") == "Product":
            return data
    return None


def _offers(data: dict) -> list[dict]:
    offers = data.get("offers")
    if isinstance(offers, dict):
        return [offers]
    if isinstance(offers, list):
        return [o for o in offers if isinstance(o, dict)]
    return []


def _cheapest_offer(data: dict) -> dict | None:
    """The duty-free tier is the lower of the two offers, so taking the minimum
    selects it regardless of the order they appear in."""
    priced: list[tuple[float, dict]] = []
    for offer in _offers(data):
        try:
            priced.append((float(offer["price"]), offer))
        except (KeyError, TypeError, ValueError):
            continue
    if not priced:
        return None
    return min(priced, key=lambda pair: pair[0])[1]


def _first_gtin(data: dict) -> str | None:
    for field in _GTIN_FIELDS:
        cleaned = clean_gtin(data.get(field))
        if cleaned:
            return cleaned
    return None


def _abv(data: dict) -> float | None:
    match = _ALCOHOL_LEVEL_RE.search(data.get("description") or "")
    if match:
        value = float(match.group(1).replace(",", "."))
        if 0 < value <= 100:
            return value
    return parse_abv(data.get("name"))


def _was_price(html_text: str) -> float | None:
    block = _DUTYFREE_BLOCK_RE.search(html_text)
    if not block:
        return None
    match = _WAS_PRICE_RE.search(block.group(0))
    if not match:
        return None
    try:
        return float(match.group(1).replace(",", ""))
    except ValueError:
        return None


def _sku(data: dict, url: str) -> str | None:
    raw = data.get("sku") or data.get("mpn")
    if raw:
        return str(raw).strip()
    match = _SKU_FROM_URL_RE.search(url)
    return match.group(1) if match else None


def listing_from_page(url: str, html_text: str) -> RawListing | None:
    """One product page as a listing; None when it carries no usable offer."""
    data = _parse_product_jsonld(html_text)
    if data is None:
        return None
    offer = _cheapest_offer(data)
    if offer is None:
        return None
    try:
        price = float(offer["price"])
    except (KeyError, TypeError, ValueError):
        return None
    if price <= 0:
        return None
    name = (data.get("name") or "").strip()
    if not name:
        return None
    sku = _sku(data, url)
    if not sku:
        return None
    brand = ((data.get("brand") or {}).get("name") or "").strip() or None
    category = (data.get("category") or "").strip()
    availability = str(offer.get("availability") or "")
    return RawListing(
        source_sku=sku,
        name=name,
        price=price,
        currency=str(offer.get("priceCurrency") or "EUR"),
        location_code=LOCATION_CODE,
        brand=brand,
        gtin=_first_gtin(data),
        was_price=_was_price(html_text),
        in_stock=("InStock" in availability) if availability else None,
        size_ml=parse_size_ml(name),
        abv=_abv(data),
        feed_categories=[c for c in (category,) if c],
        url=str(offer.get("url") or url),
        price_type="duty_free",
        # The schema.org block minus description and image, plus the one fact
        # that lives only in the markup.
        raw={"jsonld": facts_only(data), "was_price": _was_price(html_text)},
        # The URL path names the tree: /alcohol/, /beauty/ ...
        vertical=vertical_from_hints(url),
    )


class TheLoop(Collector):
    slug = "the-loop-dublin-cork"
    retailer_slug = "the-loop"
    retailer_name = "The Loop (ARI)"
    operator = "Aer Rianta International"
    homepage = BASE
    parser_version = PARSER_VERSION

    def locations(self) -> list[LocationSpec]:
        return [
            LocationSpec(
                code=LOCATION_CODE,
                iata="DUB",
                name="Dublin & Cork",
                city="Dublin",
                country="Ireland",
                currency="EUR",
            )
        ]

    def read_one(self, listing) -> RawListing | None:
        ref = listing_ref(listing)
        if not ref.url:
            return None
        # The recorded URL carries "?lang=en_IE"; the page is the same without
        # it and the query-string rules in robots.txt never come into play.
        url = ref.url.split("?", 1)[0]
        robots = check_allowed(BASE, [SITEMAP_INDEX_PATH], fresh=False)
        if not robots.allows(url):
            raise SourceBlocked(f"{LOCATION_CODE}: robots.txt disallows {url}")
        try:
            page = fetch(url, accept="text/html", delay=robots.delay_for(1.0))
        except FetchError as exc:
            if gone(exc):
                return None
            raise
        row = listing_from_page(url, page.text)
        if row is None or row.source_sku != ref.source_sku:
            return None
        return row

    def collect(self, *, limit: int | None = None, delay: float = 1.0) -> Iterator[RawListing]:
        robots = check_allowed(BASE, [SITEMAP_INDEX_PATH, "/alcohol/", "/beauty/"])
        delay = robots.delay_for(delay)
        targets = load_beauty_targets()
        if not targets:
            logger.info("beauty_targets_absent source=%s: beauty pages skipped this run", self.slug)
        produced = 0
        first = True
        for url, _vertical in wanted_urls(list(_sitemap_urls(delay)), targets):
            if not robots.allows(url):
                continue
            try:
                page = fetch(url, accept="text/html", delay=0.0 if first else delay)
            except FetchError:
                # A delisted product (410) or a one-off hiccup on a single page
                # must not end the run -- the sitemap lags the catalogue, so a
                # few dead URLs on every pass are normal.
                first = False
                continue
            first = False
            listing = listing_from_page(url, page.text)
            if listing is None:
                report_skip("no_offer", url=url)
                continue
            yield listing
            produced += 1
            if limit and produced >= limit:
                return
