"""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 location currently offers it."""

    source_sku: str
    name: str
    price: float
    currency: str
    location_code: str
    brand: str | None = None
    gtin: str | None = None
    was_price: float | None = None
    in_stock: bool | None = None
    size_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


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.size_ml, "ml") if raw.size_ml else None)
    return parse_quantity(raw.name, hint=hint, hint_rank="structured", category=category)


@dataclass(slots=True)
class LocationSpec:
    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 location code, nothing else.
    """

    source_sku: str
    url: str | None
    location_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, "location_code", None)
    if code is None:
        location = getattr(listing, "location", None)
        code = getattr(location, "code", None)
    if code is None:
        raise TypeError("read_one needs a listing with a location 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),
        location_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 locations
    (`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 locations(self) -> list[LocationSpec]: ...

    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().
        """
        ...
