"""The listed layer: what the shop showed, read back from the fragment the collector kept
(Stream L, LT5; rian, 14 Sep: LISTED is the words exactly as the shop showed them, never
changed).

Sources of truth: this module, `models/catalog.py` (Listing's `listed_*` columns),
`cli.backfill_listed`, `ingest.run_collector` (the hook after the raw record is written),
`tests/test_listed.py`, `tests/test_listings_view.py`. A leaf module: it imports only the
normalising helpers, the quantity parser, the taxonomy and one price reader, so ingest and
the collectors page can both call it without a cycle (`collector_view` imports `verify`,
which imports `ingest`).

Two readers over one fragment, one per platform, each taking a field from where that
platform's collector takes it (`COLLECTORS.md`): `collected_tile` is what the review page
shows (name, brand, size, price); `listed_fields` is what `listings` stores. The output of
`listed_fields` is written as ONE unit, every listed column plus `listed_record_id`, NULLs
included, so a newer fragment that lacks a field nulls it rather than leaving an older
sighting's value beside new ones; one listing's listed columns never mix two fragments. A
listed text is `html.unescape`d the way ingest does and never trimmed or cased; NULL where
the fragment has no such field; never from Product fields (a backfill that copied our text
would label it as the shop's). `listed_brand_key` is slug-shaped (`brand_key` with hyphens,
the fold `brands.slug` uses) and `listed_name_key` the `flat_key` fold, so "the shop's brand
or name differs from ours" is a SQL compare. A family barcode Extime puts on a `::size` row
is stored as listed and never fed to `Product.gtin` or the veto.
"""

from __future__ import annotations

import html
import re
from typing import Any

from app.services.collectors.changi import channel_price as changi_price
from app.services.normalize import brand_key, flat_key, gtin_from_sku, parse_size, parse_size_ml, size_ml_of
from app.services.quantity import parse_quantity

_MONEY_RE = re.compile(r"[^0-9.,]")


def _num(value: Any) -> float | None:
    """A number out of whatever a feed put in a price field: 176, "176.00", "1,234.5", None."""
    if value is None or isinstance(value, bool):
        return None
    if isinstance(value, int | float):
        return float(value)
    text = _MONEY_RE.sub("", str(value))
    if not text:
        return None
    if text.count(",") == 1 and "." not in text and len(text.rsplit(",", 1)[1]) in (1, 2):
        text = text.replace(",", ".")  # a decimal comma
    else:
        text = text.replace(",", "")  # thousands separators
    try:
        return float(text)
    except ValueError:
        return None


def _text(value: Any) -> str | None:
    """A field as text, with the HTML entities a feed ships ("B&amp;G") decoded, as ingest does."""
    text = html.unescape(str(value)).strip() if value is not None else ""
    return text or None


def _brand_name(value: Any) -> str | None:
    """A brand as JSON-LD writes it (an object with a name) or as a feed does (a string)."""
    if isinstance(value, dict):
        value = value.get("name")
    return _text(value)


def _stated_size(name: str | None, size_ml: Any = None) -> tuple[str | None, int | None]:
    """The size as the tile states it, and its millilitres; the collector's figure when the name is silent."""
    stated = parse_size(name)
    if stated:
        value, unit = stated
        return f"{value:g} {unit}", size_ml_of(value, unit)
    millilitres = _num(size_ml)
    if millilitres:
        return f"{millilitres:g} ml", int(millilitres)
    return None, None


def _platform(parser_version: str | None) -> str:
    """The platform a raw record's parser belongs to: the versioned prefix ("avolta/2026-09-05"),
    or, for a record stamped with a bare collector slug, that collector's module."""
    from app.services.collectors.registry import COLLECTORS  # the registry pulls every collector in; only here

    prefix = (parser_version or "").split("/", 1)[0]
    collector = COLLECTORS.get(prefix)
    if collector is not None:
        prefix = type(collector).__module__.rsplit(".", 1)[-1]
    return "heinemann" if prefix in ("heinemann_platform", "heinemann-platform") else prefix


def collected_tile(parser_version: str | None, payload: dict | None, *, currency: str | None = None) -> dict[str, Any]:
    """The tile as the shop showed it, read back out of the fragment the collector kept.

    One reader per platform, taking each field from where that platform's collector takes
    it (`COLLECTORS.md`), so the review page shows what the collector saw and not a second
    parse of it. A shape this does not know yields empty fields, never a guess; a field
    the fragment does not carry stays empty. The currency is the fragment's where it
    declares one, else the location's, passed in by the caller.
    """
    platform = _platform(parser_version)
    p = payload if isinstance(payload, dict) else {}
    name = brand = size = None
    size_ml: int | None = None
    price = was = None
    if platform == "avolta":
        tile, variant = p.get("tile") or {}, p.get("variant") or {}
        name, brand = _text(tile.get("name")), _text(tile.get("brand"))
        price = _num(variant.get("price", tile.get("price")))
        was = _num(variant.get("was_price", tile.get("was_price")))
        size, size_ml = _stated_size(name, variant.get("size_ml"))
    elif platform == "shopify":
        product, variant = p.get("product") or {}, p.get("variant") or {}
        name, brand = _text(product.get("title")), _text(product.get("vendor"))
        option = _text(variant.get("title")) or _text(variant.get("option1"))
        size, size_ml = _stated_size(option if option and option.lower() != "default title" else name)
        price, was = _num(variant.get("price")), _num(variant.get("compare_at_price"))
    elif platform == "ari":
        ld = p.get("jsonld") or {}
        offers = ld.get("offers") or {}
        if isinstance(offers, list):
            offers = offers[0] if offers and isinstance(offers[0], dict) else {}
        name, brand = _text(ld.get("name")), _brand_name(ld.get("brand"))
        price, was = _num(offers.get("price")), _num(p.get("was_price"))
        currency = _text(offers.get("priceCurrency")) or currency
        size, size_ml = _stated_size(name)
    elif platform == "extime":
        ld, variation = p.get("jsonld") or {}, p.get("variation") or {}
        offers = ld.get("offers") or []
        first = offers[0] if isinstance(offers, list) and offers and isinstance(offers[0], dict) else (
            offers if isinstance(offers, dict) else {})
        name = _text(variation.get("product_name")) or _text(ld.get("name"))
        brand = _brand_name(ld.get("brand")) or _text((p.get("main_offer") or {}).get("brand_name"))
        tier = variation.get("duty_free") if isinstance(variation.get("duty_free"), dict) else {}
        price, was = _num(tier.get("price")), _num(tier.get("price_crossed"))
        currency = _text(first.get("priceCurrency")) or currency
        unit = _text(variation.get("capacity_unit"))
        capacity = _num(variation.get("capacity"))
        size = _text(variation.get("name")) or (f"{capacity:g} {unit}" if capacity and unit else None)
        size_ml = size_ml_of(capacity, unit) if capacity and unit else None
        if size_ml is None:
            size, size_ml = _stated_size(size or name)
    elif platform == "shilla":
        specs, tiers, hidden = p.get("specs") or {}, p.get("tiers") or {}, p.get("hidden") or {}
        name, brand = _text(p.get("name")), _text(p.get("brand"))
        price = _num(hidden.get("prdPriceDollar")) or _num(tiers.get("discount")) or _num(tiers.get("list"))
        listed = _num(tiers.get("list"))
        was = listed if listed and price and listed > price else None
        currency = "USD"
        stated = _text(specs.get("Weight,Volume")) or _text(specs.get("Volume"))
        size, size_ml = (stated, parse_size_ml(stated)) if stated else _stated_size(name)
    elif platform == "changi":
        variant, offer = p.get("variant") or {}, p.get("offer") or {}
        name, brand = _text(p.get("name")), _text(p.get("brandName"))
        price, was = changi_price(offer) if isinstance(offer, dict) and offer else (None, None)
        currency = "SGD"
        qualifiers = variant.get("variantOptionQualifiers") or []
        measure = next((_text(q.get("value")) for q in qualifiers
                        if isinstance(q, dict) and q.get("qualifier") == "level1saleMeasure"), None)
        size, size_ml = (measure, parse_size_ml(measure)) if measure else _stated_size(name)
    elif platform == "dubai":
        name, brand = _text(p.get("displayName")), _text(p.get("brand"))
        sale, listed = _num(p.get("salePrice")), _num(p.get("listPrice"))
        price = sale or listed
        was = listed if sale and listed and listed > sale else None
        size, size_ml = _stated_size(name)
    elif platform == "heinemann":
        name = _text(p.get("name"))
        brand = _text(p.get("brand")) or _text(p.get("manufacturerName"))
        price = _num((p.get("price") or {}).get("value"))
        was = _num((p.get("strikethroughPrice") or {}).get("value"))
        content = p.get("contentUnit") or {}
        quantity = _num(content.get("quantity"))
        unit = (_text((content.get("unit") or {}).get("code")) or "").lower()
        if quantity and unit:
            size, size_ml = f"{quantity:g} {unit}", size_ml_of(quantity, unit)
        else:
            size, size_ml = _stated_size(name)
    return {"name": name, "brand": brand, "size": size, "size_ml": size_ml,
            "price": price, "was_price": was, "currency": currency}


# --------------------------------------------------------------------------- the listed columns

LISTED_FIELDS = ("listed_brand", "listed_name", "listed_variant", "listed_quantity_text", "listed_category",
                 "listed_gtin", "listed_brand_key", "listed_name_key", "listed_quantity_value", "listed_quantity_unit")


def _raw(value: Any) -> str | None:
    """A field as the shop wrote it: entities decoded as ingest does, nothing trimmed or cased;
    None for nothing."""
    if value is None or isinstance(value, bool | dict | list):
        return None
    text = html.unescape(str(value))
    return text if text.strip() else None


def _brand_raw(value: Any) -> str | None:
    if isinstance(value, dict):
        value = value.get("name")
    return _raw(value)


def _joined(parts: Any) -> str | None:
    """A category path as the shop lists it, joined with " > "."""
    if not isinstance(parts, list):
        return _raw(parts)
    names = [_raw(p.get("name") if isinstance(p, dict) else p) for p in parts]
    names = [n for n in names if n]
    return " > ".join(names) if names else None


def _quantity_words(text: str | None) -> str | None:
    """The size words inside a name, as written ("10cl", "75 ML/2.5OZ" is the spec's own):
    the exact slice of the first quantity statement, for platforms with no size field."""
    from app.services.quantity import _PACK_RE, _SINGLE_RE

    if not text:
        return None
    m = _PACK_RE.search(text) or _SINGLE_RE.search(text)
    return text[m.start(): m.end()] if m else None


def _valid_gtin(value: Any) -> str | None:
    return gtin_from_sku(value) if value is not None else None


def listed_fields(parser_version: str | None, payload: dict | None) -> dict[str, Any]:
    """The listed columns from one fragment: every key in `LISTED_FIELDS`, None where the
    platform has no such field. Never from RawListing.name (that is already ours: some
    collectors prepend the brand or append a size label)."""
    platform = _platform(parser_version)
    p = payload if isinstance(payload, dict) else {}
    brand = name = variant = quantity_text = category = gtin = None
    if platform == "avolta":
        tile = p.get("tile") or {}
        name, brand = _raw(tile.get("name")), _raw(tile.get("brand"))
        url = _raw(p.get("category_url"))
        if url:
            path = url.split("://")[-1].split("?")[0].split("/")
            words = [seg for seg in path[1:] if seg and not seg.isdigit() and seg not in ("en", "es", "fr")]
            category = " > ".join(words) if words else None
        quantity_text = _quantity_words(name)
        gtin = _valid_gtin(str(tile.get("sku") or "").split("::")[0]) if tile.get("sku") else None
    elif platform == "shopify":
        product, var = p.get("product") or {}, p.get("variant") or {}
        name, brand = _raw(product.get("title")), _raw(product.get("vendor"))
        option = _raw(var.get("title")) or _raw(var.get("option1"))
        if option and option.strip().lower() != "default title":
            variant = option
        quantity_text = variant if variant and parse_quantity(variant).state == "stated" else _quantity_words(name)
        category = _raw(product.get("product_type"))
        # The public feed carries no barcode.
    elif platform == "ari":
        ld = p.get("jsonld") or {}
        name, brand = _raw(ld.get("name")), _brand_raw(ld.get("brand"))
        category = _raw(ld.get("category"))
        quantity_text = _quantity_words(name)
        # ARI fragments carry only sku and mpn; a supplier code is a barcode only when it
        # already is one of the real GTIN widths (`gtin_from_sku`), sku first, then mpn.
        gtin = _valid_gtin(ld.get("sku")) or _valid_gtin(ld.get("mpn"))
    elif platform == "extime":
        ld, variation, main = p.get("jsonld") or {}, p.get("variation") or {}, p.get("main_offer") or {}
        name = _raw(variation.get("product_name")) or _raw(ld.get("name"))
        brand = _brand_raw(ld.get("brand")) or _raw(main.get("brand_name"))
        unit, capacity = _raw(variation.get("capacity_unit")), variation.get("capacity")
        quantity_text = _raw(variation.get("name")) or (f"{capacity} {unit}" if capacity and unit else None)
        variant = _raw(variation.get("name"))
        category = _joined(main.get("categories_name"))
        # The family barcode on a ::size row: stored as listed, never fed to Product.gtin.
        gtin = _valid_gtin(main.get("gtin")) or _valid_gtin(variation.get("gtin"))
    elif platform == "shilla":
        specs = p.get("specs") or {}
        name, brand = _raw(p.get("name")), _raw(p.get("brand"))
        quantity_text = _raw(specs.get("Weight,Volume")) or _raw(specs.get("Volume"))
        category = _joined(p.get("categories"))
        gtin = _valid_gtin(p.get("ref_no"))
    elif platform == "changi":
        name, brand = _raw(p.get("name")), _raw(p.get("brandName"))
        crumbs = p.get("breadcrumb") or []
        category = _joined(list(reversed(crumbs))) if isinstance(crumbs, list) else None
        variant_data = p.get("variant") or {}
        for qualifier in variant_data.get("variantOptionQualifiers") or []:
            if isinstance(qualifier, dict) and qualifier.get("qualifier") == "level1saleMeasure":
                quantity_text = _raw(qualifier.get("value"))
                break
        if quantity_text is None:
            quantity_text = _quantity_words(name)
    elif platform == "dubai":
        name, brand = _raw(p.get("displayName")), _raw(p.get("brand"))
        gtin = _valid_gtin(p.get("x_gTIN"))
        quantity_text = _quantity_words(name)
        # The feed carries category ids only, so the path stays NULL.
    elif platform == "heinemann":
        name = _raw(p.get("name"))
        brand = _raw(p.get("brand")) or _raw(p.get("manufacturerName"))
        content = p.get("contentUnit") or {}
        quantity = content.get("quantity")
        code = _raw((content.get("unit") or {}).get("code"))
        quantity_text = f"{quantity} {code}" if quantity and code else _quantity_words(name)
        category = _raw(p.get("typeOfGoods"))
        gtin = _valid_gtin(p.get("gtin")) or _valid_gtin(p.get("ean"))
    q = parse_quantity(quantity_text or name) if (quantity_text or name) else None
    return {
        "listed_brand": brand, "listed_name": name, "listed_variant": variant,
        "listed_quantity_text": quantity_text, "listed_category": category, "listed_gtin": gtin,
        "listed_brand_key": (brand_key(brand).replace(" ", "-")[:160] or None) if brand else None,
        "listed_name_key": (flat_key(name)[:400] or None) if name else None,
        "listed_quantity_value": q.value if q is not None and q.state == "stated" else None,
        "listed_quantity_unit": q.unit if q is not None and q.state == "stated" else None,
    }


def write_listed(listing: Any, record_id: int | None, parser_version: str | None, payload: dict | None) -> bool:
    """The reader's output onto a listing as one unit, `listed_record_id` included; returns
    whether anything changed. Both writers (ingest, `backfill listed`) come through here."""
    fields = listed_fields(parser_version, payload)
    fields["listed_record_id"] = record_id
    changed = False
    for key, value in fields.items():
        current = getattr(listing, key)
        if key == "listed_quantity_value" and current is not None and value is not None:
            same = float(current) == float(value)
        else:
            same = current == value
        if not same:
            setattr(listing, key, value)
            changed = True
    return changed
