"""The quantity a product comes in, read from text: identity rules v4 (Stream L, 15 Sep).

Sources of truth: this module, `normalize.match_key` (the v4 key), `models/catalog.py`
(the quantity columns on ProductVariant), `tests/test_quantity.py`. Rian, 14 Sep: the measurable
thing is a QUANTITY with a unit, not a size; "size" is kept back for apparel. Rian, 15 Sep:
every key carries its unit, one rule for every dimension, and unknown is a word, never a
zero, so 100 ml can never equal 100 g and an unknown can never look like a measurement.

What a quantity is: a value in one of three canonical units, millilitres (every volume word:
l, cl, ml, fl oz), grams (g, gr, kg, a bare oz for a cream or a bar) or pieces (pcs, sticks,
capsules, bags), with the form the name declares: `single`, `pack` (N x one quantity, the
total kept with the count and the unit value), `set` (several parts, every part kept sorted
and mixed units allowed) or `refill`. `state` says whether a quantity was read at all:
`stated`, `none` (the text names no figure) or `unparsed` (a number with a unit was seen but
not understood: a pound, a code a shop's own field used).

Precedence, in one place (tested in `tests/test_quantity.py`):
  1. a decided `quantity` override on the product (read by `keying.product_key`; never here);
  2. a structured shop field (Heinemann `contentUnit`, Extime `capacity`, Changi
     `saleMeasureType`), passed as `hint` with `hint_rank="structured"`, when the text is
     silent or agrees in dimension;
  3. the text, when it names a different dimension than the hint, a set or a multipack; the
     disagreement is recorded (`state` stays `stated`, `detail` `hint_disagrees`);
  4. the hint, when the text is silent.
A stored v3 pair (`quantity_stated_value`, `quantity_stated_unit`) ranks BELOW the text (`hint_rank="stored"`, the
default): it is our past parse, not the shop's word. That is what makes product 7293 "Dior
Sauvage Stick Deodorant 75g" with a stored 75 ml come out 75 g, 14386 "Benefit Pop, Lock &
Lash It 4g + 3.5ml" with a stored 35 ml come out a set, and 1583 "0.375L" stay 375 ml when
the stored pair had been rounded to 0.38 l.

Ounces: a metric statement anywhere in the text always beats an imperial one ("Pure Musc
3.3 Oz 5cl" is 50 ml). `fl oz` is always millilitres. A bare `oz` is fluid ounces for
Perfume and for every drink, and grams for confectionery, Makeup and Skincare. The converted
figure snaps to the nearest nominal metric size within four percent for millilitres (1.7 fl
oz is the 50 ml bottle, 3.4 oz the 100 ml) and six percent for grams (a 1 oz cream is sold
as 30 g); otherwise it rounds to the whole unit.

The key slot (`quantity_key`) is spelled here and nowhere else: value then unit, `100ml`,
`50g`, `200pcs`; the millilitre token goes through `normalize.quantity_ml_of`, the same Python
`round()` v3 used ("92.5 ml" is `92ml`, "37.5 ml" is `38ml`), never `int()`; a pack keys on
its total in its dimension with the form `pack` (`2000ml|pack`, `480g|pack`); a set keys on
its unit-suffixed sorted contents (`5ml+15ml+15ml+50ml|set`, `3.5ml+4g|set`), `unknown|set`
when no part states a size; `unknown` when nothing was read. A set's `quantity_value` is the
sum when every part shares a unit and NULL when mixed; `set_contents` is always kept; a bare
piece count on a set ("4 Pieces Mini Travel Set") is recorded as 4 pcs but the slot stays
`unknown|set` and the state `none`, so two such sets never auto-fold on a count alone.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, replace

from app.services.normalize import is_multipack, looks_like_set, quantity_ml_of

UNITS = ("ml", "g", "pcs")
FORMS = ("single", "pack", "set", "refill")
STATES = ("stated", "none", "unparsed")

# The most a single item of each family comes in, in millilitres (moved here from
# `normalize.SIZE_CEILING_ML`; `normalize.size_is_implausible` wraps it). A stored size
# above this that the product's own name does not state is a misread, not a magnum: the
# first audit found two mists at 7,624 and 5,924 ml (a retailer's capacity field) and two
# whiskies at 7,000 ml (a "700cl" reading). Unknown beats wrong.
CEILING_ML: dict[str, int] = {"beauty": 1000, "liquor": 6000, "confectionery": 5000, "tobacco": 5000}
DEFAULT_CEILING_ML = 6000

_VOLUME = {
    "ml": 1.0, "millilitre": 1.0, "millilitres": 1.0, "milliliter": 1.0, "milliliters": 1.0,
    "cl": 10.0, "centilitre": 10.0, "centilitres": 10.0, "centiliter": 10.0,
    "l": 1000.0, "litre": 1000.0, "litres": 1000.0, "liter": 1000.0, "liters": 1000.0, "ltr": 1000.0, "lt": 1000.0,
}
_MASS = {"g": 1.0, "gr": 1.0, "grs": 1.0, "gram": 1.0, "grams": 1.0, "gramm": 1.0, "gramos": 1.0,
         "kg": 1000.0, "kilo": 1000.0, "kilos": 1000.0, "kilogram": 1000.0, "kilograms": 1000.0}
_COUNT = {"pcs", "pc", "pce", "piece", "pieces", "stick", "sticks", "tablet", "tablets", "capsule",
          "capsules", "caps", "bag", "bags", "sachet", "sachets", "pod", "pods", "pieza", "piezas"}
# Units a shop states that we do not convert: a number beside one is seen, not understood.
_UNPARSED = {"lb", "lbs", "mg", "cc", "gal", "gallon", "gallons", "pt", "qt", "ct", "cts"}
_FL_OZ = r"fl\.?\s*oz\.?|floz"
_OZ = r"oz\.?|ounce|ounces"
_ANY_UNIT = "|".join(
    [_FL_OZ, _OZ]
    + sorted((*_VOLUME, *_MASS, *_COUNT, *_UNPARSED), key=len, reverse=True)
)
_NUM = r"\d+(?:[.,]\d+)?"
# "l" must not read the L of "N°5 L'EAU": a litre is never followed by an apostrophe.
_UNIT_TAIL = r"(?![a-z0-9'’])"
_PACK_RE = re.compile(
    rf"(?<![0-9a-z])(\d{{1,2}})\s*(?:bottles?\s*|cans?\s*|pack\s*|bars?\s*|bags?\s*)?[x×]\s*({_NUM})\s*({_ANY_UNIT}){_UNIT_TAIL}",
    re.I,
)
# The lookbehind lets a glued multiplier through: Extime's "X20 Pieces" is a count of 20
# pieces, never a pack without a unit.
_SINGLE_RE = re.compile(rf"(?<![0-9a-wyz.,])({_NUM})\s*({_ANY_UNIT}){_UNIT_TAIL}", re.I)
# The whole glued count, multiplier included, for the line rules to strip ("X20 Pieces").
_GLUED_COUNT_RE = re.compile(rf"(?<![0-9a-z])[x\u00d7]({_NUM})\s*({_ANY_UNIT}){_UNIT_TAIL}", re.I)

_NOMINAL_ML = (3, 4, 5, 7, 7.5, 8, 10, 12, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 75, 80, 90, 100,
               110, 120, 125, 150, 175, 200, 240, 250, 300, 350, 375, 400, 450, 500, 600, 700, 750, 1000)
_NOMINAL_G = (5, 7, 10, 15, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 100, 120, 125, 150, 175, 200, 250,
              300, 350, 400, 450, 500, 750, 1000)
_ML_PER_FL_OZ = 29.5735
_G_PER_OZ = 28.3495
# Categories where a bare "oz" is a weight, not a fluid ounce.
_MASS_CATEGORIES = frozenset({"Makeup", "Skincare"})


@dataclass(frozen=True, slots=True)
class Quantity:
    """What was read: the value in its canonical unit, the pack and set structure, the form,
    and whether anything was read at all. `detail` records a disagreement with the hint."""

    value: float | None = None
    unit: str | None = None
    pack_count: int | None = None
    pack_unit_value: float | None = None
    form: str = "single"
    set_contents: str | None = None
    state: str = "none"
    detail: str | None = None

    @property
    def ml(self) -> int | None:
        """The millilitre figure the public site and `quantity_ml` keep: the value for a volume,
        the total for a volume pack, the sum for an all-millilitre set, else None."""
        if self.unit == "ml" and self.value is not None:
            return quantity_ml_of(self.value, "ml")
        return None

    @property
    def dimension(self) -> str | None:
        return self.unit


NONE = Quantity()


def _num(value: float) -> str:
    """A number as the slot spells it: no trailing zeros, no exponent ("3.5", "50", "0.375")."""
    text = f"{float(value):.3f}".rstrip("0").rstrip(".")
    return text or "0"


def _token(value: float, unit: str, *, exact: bool = False) -> str:
    """One part of the slot: value then unit; a single item's millilitres through the same
    rounding v3 used; a set's parts exact (`3.5ml+4g`), so a part is never rounded into
    another (`exact`)."""
    if unit == "ml" and not exact:
        return f"{quantity_ml_of(value, 'ml')}ml"
    if unit == "pcs":
        return f"{int(round(value))}pcs"
    return f"{_num(value)}{unit}"


def quantity_key(q: Quantity | None) -> str:
    """The key slot, and the form suffix that rides with it (`100ml`, `480g|pack`,
    `5ml+15ml+15ml+50ml|set`, `unknown`, `unknown|set`). The one place it is spelled."""
    if q is None:
        return "unknown"
    if q.form == "set":
        body = q.set_contents or "unknown"
        return f"{body}|set"
    if q.state != "stated" or q.value is None or q.unit is None:
        body = "unknown"
    else:
        body = _token(q.value, q.unit)
    if q.form in ("pack", "refill"):
        return f"{body}|{q.form}"
    return body


def _snap(value: float, nominal: tuple, tolerance: float) -> float:
    nearest = min(nominal, key=lambda n: abs(n - value))
    if abs(nearest - value) / nearest <= tolerance:
        return float(nearest)
    return float(round(value))


def _oz_is_mass(category: str | None) -> bool:
    from app.services.taxonomy import vertical_of  # taxonomy imports normalize lazily; no cycle

    if category in _MASS_CATEGORIES:
        return True
    return vertical_of(category) == "confectionery"


def _convert(value: float, unit: str, category: str | None) -> tuple[float, str] | None:
    """A stated figure in its canonical unit: (value, ml | g | pcs); None for a unit we
    only recognise ("unparsed")."""
    u = unit.lower().replace(" ", "").rstrip(".")
    if re.fullmatch(r"fl\.?oz|floz", u):
        return _snap(value * _ML_PER_FL_OZ, _NOMINAL_ML, 0.04), "ml"
    if u in ("oz", "ounce", "ounces"):
        if _oz_is_mass(category):
            return _snap(value * _G_PER_OZ, _NOMINAL_G, 0.06), "g"
        return _snap(value * _ML_PER_FL_OZ, _NOMINAL_ML, 0.04), "ml"
    if u in _VOLUME:
        factor = _VOLUME[u]
        # A sub-unit centilitre value is a typo for litres: "0.70cl" means 0.70 L; a
        # genuine 7 ml bottle of spirits does not exist (normalize.parse_size).
        if u == "cl" and value < 1:
            factor = 1000.0
        return value * factor, "ml"
    if u in _MASS:
        return value * _MASS[u], "g"
    if u in _COUNT:
        return float(int(value)), "pcs"
    return None


@dataclass(frozen=True, slots=True)
class _Item:
    value: float
    unit: str
    count: int = 1
    imperial: bool = False


def _items(text: str, category: str | None) -> tuple[list[_Item], bool]:
    """Every quantity the text states, packs as one item with a count, imperial ones
    flagged; and whether a number beside a unit we cannot convert was seen."""
    items: list[_Item] = []
    unparsed = False
    spans: list[tuple[int, int]] = []
    for m in _PACK_RE.finditer(text):
        converted = _convert(float(m.group(2).replace(",", ".")), m.group(3), category)
        spans.append(m.span())
        if converted is None:
            unparsed = True
            continue
        value, unit = converted
        items.append(_Item(value, unit, int(m.group(1)), imperial=_is_oz(m.group(3))))
    for m in _SINGLE_RE.finditer(text):
        if any(a <= m.start() < b for a, b in spans):
            continue
        converted = _convert(float(m.group(1).replace(",", ".")), m.group(2), category)
        if converted is None:
            unparsed = True
            continue
        value, unit = converted
        items.append(_Item(value, unit, 1, imperial=_is_oz(m.group(2))))
    if any(not i.imperial for i in items):
        items = [i for i in items if not i.imperial]
    return items, unparsed


def _is_oz(unit: str) -> bool:
    u = unit.lower().replace(" ", "").rstrip(".")
    return bool(re.fullmatch(r"fl\.?oz|floz|oz|ounces?", u))


def _plausible(value: float, unit: str, count: int) -> bool:
    if value <= 0:
        return False
    total = value * count
    if unit == "ml":
        return 1 <= total <= 50000
    if unit == "g":
        return 0.1 <= total <= 50000
    return total <= 10000


def _from_items(text: str, items: list[_Item]) -> Quantity:
    """The quantity the text's own statements make: a set, a pack, a single, or none."""
    refill = bool(_REFILL_RE.search(text))
    is_set = looks_like_set(text)
    items = [i for i in items if _plausible(i.value, i.unit, i.count)]
    # Two equal statements of one figure ("1L (1000ml)") are one statement; joined by a
    # plus ("0.5L+0.5L", a bottle with its gift) they are two parts.
    distinct = {(_token(i.value, i.unit), i.count) for i in items}
    if not is_set and len(distinct) == 1 and "+" not in text:
        items = items[:1]
    packs = [i for i in items if i.count > 1]
    if not is_set and packs:
        # A pack with an extra beside it ("Twin Pack 2x1L With Free Raspberri 375ml") is
        # the pack; only a set word makes the parts a set.
        items = packs[:1]
    if is_set or len(items) >= 2:
        parts = [(i.value, i.unit) for i in items for _ in range(i.count)]
        counts = [p for p in parts if p[1] == "pcs"]
        measured = [p for p in parts if p[1] != "pcs"]
        if not measured:
            # A bare piece count on a set is recorded but never keys it.
            if counts:
                return Quantity(value=sum(v for v, _ in counts), unit="pcs", form="set", state="none")
            return Quantity(form="set", state="none")
        measured.sort(key=lambda p: (p[0], p[1]))
        contents = "+".join(_token(v, u, exact=True) for v, u in measured)
        units = {u for _, u in measured}
        # Rounded to the column's three decimals so a rerun reads back what it wrote.
        value = round(sum(v for v, _ in measured), 3) if len(units) == 1 else None
        unit = next(iter(units)) if len(units) == 1 else None
        return Quantity(value=value, unit=unit, form="set", set_contents=contents, state="stated")
    if not items:
        # A feed's own suffix glued to the unit ("24x0.33LDS") hides the figure but not the
        # form; the hint (the collector's total) fills the figure.
        form = "refill" if refill else ("pack" if is_multipack(text) else "single")
        return Quantity(form=form, state="none")
    item = items[0]
    if item.count > 1:
        return Quantity(value=round(item.value * item.count, 3), unit=item.unit, pack_count=item.count,
                        pack_unit_value=item.value, form="pack", state="stated")
    return Quantity(value=item.value, unit=item.unit, form="refill" if refill else "single", state="stated")


_REFILL_RE = re.compile(r"\b(refill|recharge|rechargeable|refillable|recargable)\b", re.I)


def as_quantity(hint: Quantity | tuple | None) -> Quantity | None:
    """A hint as a Quantity: a Quantity as it is, a (value, unit) pair converted, None for
    an empty pair or an unknown unit."""
    if hint is None or isinstance(hint, Quantity):
        return hint
    value, unit = hint
    if value is None or not unit:
        return None
    converted = _convert(float(value), str(unit), None)
    if converted is None:
        return Quantity(state="unparsed", detail=f"unit:{unit}")
    v, u = converted
    return Quantity(value=v, unit=u, form="single", state="stated")


def parse_quantity(text: str | None, *, hint: Quantity | tuple | None = None,
                   hint_rank: str = "stored", category: str | None = None) -> Quantity:
    """The quantity of a product from its text and, when given, the hint: the precedence in
    the module docstring. `hint_rank` is "structured" for a shop's own field, "stored" for
    our past parse."""
    hint_q = as_quantity(hint)
    items, unparsed = _items(text or "", category)
    read = _from_items(text or "", items)
    if read.state == "stated":
        if hint_q is not None and hint_q.state == "stated":
            same_dimension = hint_q.unit == read.unit and read.form in ("single", "refill")
            if same_dimension and hint_rank == "structured":
                return replace(read, value=hint_q.value, pack_count=None, pack_unit_value=None,
                               detail=None if hint_q.value == read.value else "text_disagrees")
            if hint_q.unit != read.unit or hint_q.value != read.value or read.form != "single":
                return replace(read, detail="hint_disagrees")
        return read
    if hint_q is not None and hint_q.state == "stated":
        if read.form == "set":
            # A silent set takes no figure from a hint: a hint cannot name the parts.
            return read
        return replace(hint_q, form=read.form)
    if read.form == "set":
        return read
    if unparsed or (hint_q is not None and hint_q.state == "unparsed"):
        return Quantity(form=read.form, state="unparsed", detail=(hint_q.detail if hint_q else None))
    return read


def from_stored(value, unit: str | None, *, pack_count=None, pack_unit_value=None, form: str | None = None,
                set_contents: str | None = None, state: str | None = None) -> Quantity:
    """A Quantity from a product's stored columns (the v4 columns, or the v3 pair)."""
    if state is None:
        state = "stated" if value is not None and unit in UNITS else "none"
    return Quantity(value=float(value) if value is not None else None, unit=unit if unit in UNITS else None,
                    pack_count=pack_count, pack_unit_value=float(pack_unit_value) if pack_unit_value is not None else None,
                    form=form or "single", set_contents=set_contents, state=state)


def is_implausible(vertical: str | None, name: str, quantity_ml: int | None) -> bool:
    """A stored size no single item of that family comes in, unless the name itself says so."""
    if not quantity_ml:
        return False
    if quantity_ml <= CEILING_ML.get(vertical or "", DEFAULT_CEILING_ML):
        return False
    q = parse_quantity(name)
    if q.form == "pack":
        return False
    return q.ml != quantity_ml
