"""Shared shape for every collector.

An adapter turns one retailer's pages into RawListing records. It knows nothing
about the database and nothing about how bytes were fetched -- which is what
lets a new retailer be added without touching anything else.

Two later additions, both decided 2026-09-04:

* `RawListing.raw` carries the parsed fragment the listing was derived from
  (the tile, the JSON-LD offer, the API item), so ingest can keep it in
  `raw_records` and any rule can be re-run over what was actually seen.
  Nothing was kept before, so a rule change meant a re-fetch.
* `read_one()` reads a single listing back from its source so verification
  can compare what we published with what the page says now.
"""

import re
from collections.abc import Callable, Iterator
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any, Protocol

from app.services.quantity import Quantity, parse_quantity

# A collector that decides not to emit something says why through here, and
# the run that is consuming it decides what to do with that (ingest records
# it in rejected_observations once the table exists). Outside a run the hook
# is a no-op, so collectors stay usable from a shell and in tests.
SkipSink = Callable[[str, dict], None]
_skip_sink: ContextVar[SkipSink | None] = ContextVar("collector_skip_sink", default=None)


def report_skip(reason: str, **detail: Any) -> None:
    """A collector-layer decision not to emit: no price, unreadable page, etc."""
    sink = _skip_sink.get()
    if sink is not None:
        sink(reason, detail)


def bind_skip_sink(sink: SkipSink | None):
    """Set the sink for the current context; returns the token to reset with."""
    return _skip_sink.set(sink)


def unbind_skip_sink(token) -> None:
    _skip_sink.reset(token)

# Keys that hold expression, not facts. A raw record is a fact store and the
# legal posture (COLLECTORS.md, L2) rests on never ingesting marketing copy or
# imagery, so anything under these names is dropped before storage, at any
# depth. Over-dropping is the safe direction: a lost field costs a re-fetch,
# a stored description hands a plaintiff the copyrighted work it needs.
_EXPRESSION_KEY_RE = re.compile(
    r"descr|body_html|image|media|picture|photo|thumbnail|video|seo|meta_?(title|desc)",
    re.I,
)


def facts_only(payload: Any, *, _depth: int = 0) -> Any:
    """A copy of a parsed fragment with every expression-bearing key removed."""
    if _depth > 12:
        return None
    if isinstance(payload, dict):
        return {
            k: facts_only(v, _depth=_depth + 1)
            for k, v in payload.items()
            if not _EXPRESSION_KEY_RE.search(str(k))
        }
    if isinstance(payload, list | tuple):
        return [facts_only(v, _depth=_depth + 1) for v in payload]
    return payload


@dataclass(slots=True)
class RawListing:
    """One product as one shop currently offers it."""

    source_sku: str
    name: str
    price: float
    currency: str
    shop_code: str
    brand: str | None = None
    gtin: str | None = None
    was_price: float | None = None
    in_stock: bool | None = None
    quantity_ml: int | None = None
    # The quantity a collector's own structured field states (Heinemann `contentUnit`,
    # Extime `capacity`, Changi `saleMeasureType`, Shilla "Weight,Volume"), in a canonical
    # unit (ml | g | pcs); None when the collector only has the name, which ingest parses
    # (`quantity_of`). `quantity_text` is the shop's own size wording, kept for the record.
    quantity: Quantity | None = None
    quantity_text: str | None = None
    abv: float | None = None
    feed_categories: list[str] = field(default_factory=list)
    country_of_origin: str | None = None
    url: str | None = None
    price_type: str = "list"
    is_exclusive: bool = False
    awards: list[str] = field(default_factory=list)
    # The fragment this listing was parsed from, facts only (see facts_only).
    raw: dict | None = None
    # The category family the collector walked to find it: liquor, beauty,
    # confectionery, tobacco. None means "the collector cannot say".
    vertical: str | None = None
    # The options the shop published as its own fields, `(the shop's name for it, the value)` in
    # the shop's order and spelling (a Shopify `Tamaño`/`Color`/`Talla`, Extime's variation name).
    # Carried as fields, never glued into `name`: a shade glued into the name was read back by a
    # name rule for a week and made 59 product lines of one lipstick (identity rules v6).
    options: list[tuple[str, str]] = field(default_factory=list)


_OPTION_NAME_RE = re.compile(r"[^a-z0-9]+")
_NO_OPTION_VALUES = frozenset({"", "default title", "default"})


def _is_quantity_statement(value: str) -> bool:
    """Whether an option's value is a quantity statement and nothing else ("400ml", "3.5 gr",
    "3 x 50 ml"): the parser reads a stated quantity and no letter or digit is left beside it."""
    from app.services.quantity import _PACK_RE, _SINGLE_RE

    if parse_quantity(value).state != "stated":
        return False
    rest = _SINGLE_RE.sub(" ", _PACK_RE.sub(" ", value))
    return not re.search(r"[^\W_]", rest)


def quantity_options(options: list[tuple[str, str]] | None) -> list[str]:
    """The option values that ARE the quantity ("Tamaño: 400ml"): they feed the typed quantity
    and are never repeated as an identity attribute, or one shop's "100 ml" and another's
    "100ml" would key apart on a fact the quantity slot already states."""
    return [str(value).strip() for _, value in (options or []) if value and _is_quantity_statement(str(value))]


def option_attributes(options: list[tuple[str, str]] | None) -> dict[str, str]:
    """`{"option:<the shop's name for it>": value}` for every published option that is not the
    quantity and not a platform's placeholder ("Default Title"): certain because the shop stated
    it as a field, identity because a shop's own option tells its variants apart (plan W7). The
    name is folded to ASCII (`Tamaño` is `option:tamano`); the value keeps the shop's spelling."""
    import unicodedata

    out: dict[str, str] = {}
    for name, value in options or []:
        text = str(value or "").strip()
        if text.lower() in _NO_OPTION_VALUES or _is_quantity_statement(text):
            continue
        folded = unicodedata.normalize("NFKD", str(name or "")).encode("ascii", "ignore").decode().lower()
        slug = _OPTION_NAME_RE.sub("_", folded).strip("_")[:40]
        if slug:
            out.setdefault(f"option:{slug}", text[:120])
    return out


def quantity_of(raw: RawListing, *, category: str | None = None) -> Quantity:
    """The quantity a listing resolves under (identity rules v4): the name parsed with the
    collector's structured quantity as the hint that outranks it within one dimension
    (`quantity.parse_quantity`, precedence in its docstring); a collector that only has a
    millilitre figure hands it over the same way, so the ml it read is the ml it keys on."""
    hint = raw.quantity if raw.quantity is not None else ((raw.quantity_ml, "ml") if raw.quantity_ml else None)
    # A quantity the shop published as an option field is read with the name, exactly the text
    # the parser saw while the collector still glued the option into the name.
    # Only when the name itself states none (Extime's name already carries the size label): the
    # same figure read twice would look like a set of two.
    text = raw.name
    stated_in_options = quantity_options(raw.options)
    if stated_in_options and parse_quantity(raw.name).state != "stated":
        text = " ".join([raw.name, *stated_in_options])
    return parse_quantity(text, hint=hint, hint_rank="structured", category=category)


@dataclass(slots=True)
class ShopSpec:
    code: str
    name: str
    currency: str
    iata: str | None = None
    city: str | None = None
    country: str | None = None
    is_catalogue_only: bool = False


@dataclass(slots=True)
class ListingRef:
    """What read_one() needs to know about a published listing.

    Built from a database Listing by `listing_ref()`, so collectors stay
    database-free: they see a SKU, a URL and a shop code, nothing else.
    """

    source_sku: str
    url: str | None
    shop_code: str
    # The family the product is filed under, when known: a re-read of a held
    # URL has no category path to infer it from.
    vertical: str | None = None


def listing_ref(listing: Any) -> ListingRef:
    """Accept a ListingRef, a database Listing, or anything shaped like one."""
    if isinstance(listing, ListingRef):
        return listing
    code = getattr(listing, "shop_code", None)
    if code is None:
        shop = getattr(listing, "shop", None)
        code = getattr(shop, "code", None)
    if code is None:
        raise TypeError("read_one needs a listing with a shop code")
    vertical = getattr(listing, "vertical", None)
    if vertical is None:
        vertical = getattr(getattr(listing, "product", None), "vertical", None)
    return ListingRef(
        source_sku=str(listing.source_sku), url=getattr(listing, "url", None),
        shop_code=code, vertical=vertical,
    )


_GONE_STATUS_RE = re.compile(r"\bHTTP (404|410)\b")


def gone(exc: Exception) -> bool:
    """Whether a fetch failure means the listing no longer exists.

    The fetch port reports the status inside the message ("... returned HTTP
    404"); the module is not ours to add an attribute to, so read it back.
    """
    return _GONE_STATUS_RE.search(str(exc)) is not None


class Collector(Protocol):
    """The contract every retailer adapter implements.

    A collector that sets `wants_held_listings = True` is handed, before each
    `collect()`, the listings the database already holds for its shops
    (`held_listings: list[ListingRef]`), so a store whose listing pages it may
    only read one page deep can re-read the product URLs it already knows
    instead of letting them vanish. Optional; nothing else reads it.
    """

    slug: str
    retailer_slug: str
    retailer_name: str
    operator: str | None
    homepage: str
    # Names the parsing rules a raw record was read with; bump when they change.
    parser_version: str

    def shops(self) -> list[ShopSpec]: ...

    def collect(self, *, limit: int | None = None, delay: float = 1.0) -> Iterator[RawListing]: ...

    def read_one(self, listing: Any) -> RawListing | None:
        """Read one published listing back from its source, fresh.

        None means the source no longer offers it (gone, delisted, not on the
        page). SourceBlocked means the source refuses; FetchError means try
        later. Opens with the same robots check as collect().
        """
        ...
