"""The attribute registry and the one accessor every reader uses (Stream K2; plan W7, W8).

An attribute is a value that tells one variant from its siblings, or a fact about a variant;
one noun, and per kind two settings: `identity` (does it decide sameness) and `display`
(picked, shown or fact on the product line page). Where a kind's value LIVES is the registry's
`storage`, the migration valve: `quantity` in its typed columns, `abv`, `country_of_origin` and
`is_exclusive` in theirs, the marked kinds (concentration, color, flavor) under the K1 shape
`attributes.attribute` + `attributes.attribute_kind`, every other kind under `attributes.<kind>`.
Moving a kind between storages is one registry line; nothing that reads changes. A shop's own
option field becomes a kind on sight (`option:<name>`), certain because the shop stated it.

Sources of truth: this module, `services/product_lines.py` (`ATTRIBUTE_KINDS`, `ATTRIBUTE_DISPLAY`
and the readers stay the one definition of what a rule reads; the registry reads them),
`services/quantity.py`, `tests/test_attributes.py`. What it cost before: a hand-kept
`GUARDED_COLUMNS` and a hand-kept `_ENRICH_FIELDS` that had to agree by luck; a new attribute
kind meant a column and a migration (plan W7).
"""

from __future__ import annotations

import builtins
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any

from app.services import product_lines
from app.services import quantity as quantity_service


class Storage(Enum):
    QUANTITY = "quantity"    # the seven typed quantity columns
    COLUMN = "column"        # one typed column named by `column`
    MARKED = "marked"        # `attributes.attribute` when `attributes.attribute_kind` is this kind (K1's shape)
    JSON = "json"            # `attributes.<kind>`


@dataclass(frozen=True)
class Kind:
    name: str
    label: str
    value_type: str                       # number | text | enum | bool | structured
    unit: str | None = None
    verticals: frozenset[str] | None = None   # None: every vertical
    categories: frozenset[str] | None = None  # None: every category of those verticals
    identity: str = "never"               # always | never
    display: str = product_lines.FACT     # picked | shown | fact
    storage: Storage = Storage.JSON
    column: str | None = None
    certain: bool = False                 # a shop-published option field; the rule reads it without review

    @property
    def field(self) -> str:
        return f"attribute:{self.name}"


QUANTITY_COLUMNS = frozenset({"quantity_ml", "quantity_stated_value", "quantity_stated_unit", "quantity_value", "quantity_unit",
                              "pack_count", "pack_unit_value", "form", "set_contents", "quantity_state"})

#: `(vertical, category)` pairs whose variants have no quantity at all (a watch, a ring, a
#: handbag): the slot reads `n/a`, which equals itself; `unknown` (a quantity the parser could not
#: read) never does. Empty until such a category is collected; a registry line, never a migration.
QUANTITY_NOT_APPLICABLE: frozenset[tuple[str, str]] = frozenset()

_MARKED = frozenset({"concentration", "color", "flavor"})

KINDS: dict[str, Kind] = {
    "quantity": Kind("quantity", "Quantity", "structured", identity="always", display=product_lines.PICKED, storage=Storage.QUANTITY),
    "abv": Kind("abv", "ABV", "number", unit="%", verticals=frozenset({"liquor"}), identity="always", display=product_lines.FACT,
                storage=Storage.COLUMN, column="abv"),
    "country_of_origin": Kind("country_of_origin", "Country of origin", "text", identity="never", display=product_lines.FACT,
                              storage=Storage.COLUMN, column="country_of_origin"),
    "is_exclusive": Kind("is_exclusive", "Travel exclusive", "bool", identity="never", display=product_lines.FACT,
                         storage=Storage.COLUMN, column="is_exclusive"),
    "age": Kind("age", "Age", "number", unit="years", verticals=frozenset({"liquor"}), identity="always", display=product_lines.FACT),
    "vintage": Kind("vintage", "Vintage", "number", verticals=frozenset({"liquor"}), identity="always", display=product_lines.FACT),
}
for _name, _label, _verticals in (("concentration", "Concentration", frozenset({"beauty"})),
                                  ("color", "Shade", frozenset({"beauty"})),
                                  ("flavor", "Flavour", frozenset({"confectionery"}))):
    KINDS[_name] = Kind(_name, _label, "text", verticals=_verticals, identity="always",
                        display=product_lines.ATTRIBUTE_DISPLAY.get(_name, product_lines.SHOWN), storage=Storage.MARKED)
for _name in ("cask", "edition"):
    KINDS[_name] = Kind(_name, _name.title(), "text", verticals=frozenset({"liquor"}), identity="always", display=product_lines.SHOWN)

_OPTION_RE = re.compile(r"[^a-z0-9]+")


def register_option(shop_name: str) -> Kind:
    """`option:<name>` for a shop's own option field (a Shopify `Talla`, `Color`): certain,
    identity, text, picked. Registered on sight; a later registry line may map it to a kind."""
    slug = _OPTION_RE.sub("_", (shop_name or "").strip().lower()).strip("_")[:40]
    if not slug:
        raise ValueError("an option kind needs the shop's name for it")
    name = f"option:{slug}"
    if name not in KINDS:
        KINDS[name] = Kind(name, shop_name.strip()[:40] or slug, "text", identity="always", display=product_lines.PICKED, certain=True)
    return KINDS[name]


def kinds_for(vertical: str | None, category: str | None) -> list[Kind]:
    out = []
    for kind in KINDS.values():
        if kind.verticals is not None and (vertical or "") not in kind.verticals:
            continue
        if kind.categories is not None and (category or "") not in kind.categories:
            continue
        out.append(kind)
    return out


def kind_of(name: str) -> Kind:
    if name in KINDS:
        return KINDS[name]
    if name.startswith("option:"):
        return register_option(name[len("option:"):])
    raise KeyError(name)


@dataclass(frozen=True)
class Attribute:
    kind: str
    value: Any
    unit: str | None
    label: str
    identity: str
    display: str


# --------------------------------------------------------------------------- read

def get(variant: Any, kind: str) -> Any:
    """The value a variant holds for a kind, from the storage the registry names; None when none."""
    k = kind_of(kind)
    attrs = variant.attributes or {}
    if k.storage is Storage.QUANTITY:
        return quantity_of(variant)
    if k.storage is Storage.COLUMN:
        return getattr(variant, k.column, None)
    if k.storage is Storage.MARKED:
        return attrs.get("attribute") if attrs.get("attribute_kind") == kind and attrs.get("attribute") else None
    return attrs.get(kind)


def quantity_of(variant: Any) -> quantity_service.Quantity | None:
    if not getattr(variant, "quantity_state", None):
        return None
    return quantity_service.from_stored(variant.quantity_value, variant.quantity_unit, pack_count=variant.pack_count,
                                        pack_unit_value=variant.pack_unit_value, form=variant.form,
                                        set_contents=variant.set_contents, state=variant.quantity_state)


def of(variant: Any) -> list[Attribute]:
    """Every kind with a value on the variant, in registry order, the marked kind and any
    `option:*` keys the shop published included."""
    out: list[Attribute] = []
    attrs = variant.attributes or {}
    for name in list(KINDS) + [k for k in attrs if k.startswith("option:") and k not in KINDS]:
        k = kind_of(name)
        value = get(variant, name)
        if value is None or value == "" or value is False or (k.storage is Storage.QUANTITY and value.state == "none"):
            continue  # a False bool is no fact to list; a True one is
        out.append(Attribute(name, value, k.unit, k.label, k.identity, k.display))
    return out


# --------------------------------------------------------------------------- write

def set(variant: Any, kind: str, value: Any) -> None:  # noqa: A001 (the brief's name)
    """Write a kind's value into the storage the registry names, validated by its value type.
    `None` clears it. The seven quantity columns take a `{value, unit, form, ...}` dict."""
    k = kind_of(kind)
    value = validate(k, value)
    if k.storage is Storage.QUANTITY:
        q = None if value is None else quantity_service.from_stored(
            value.get("value"), value.get("unit"), pack_count=value.get("pack_count"), pack_unit_value=value.get("pack_unit_value"),
            form=value.get("form") or "single", set_contents=value.get("set_contents"),
            state="stated" if value.get("value") is not None or value.get("set_contents") else "none")
        variant.quantity_value = q.value if q else None
        variant.quantity_unit = q.unit if q else None
        variant.pack_count = q.pack_count if q else None
        variant.pack_unit_value = q.pack_unit_value if q else None
        variant.form = q.form if q else None
        variant.set_contents = q.set_contents if q else None
        variant.quantity_state = q.state if q else None
        variant.quantity_ml = q.ml if q else None
        return
    if k.storage is Storage.COLUMN:
        setattr(variant, k.column, value)
        return
    attrs = dict(variant.attributes or {})
    if k.storage is Storage.MARKED:
        if value is None:
            if attrs.get("attribute_kind") == kind:
                attrs.pop("attribute", None); attrs.pop("attribute_kind", None)
        else:
            attrs["attribute"], attrs["attribute_kind"] = value, kind
    elif value is None:
        attrs.pop(kind, None)
    else:
        attrs[kind] = value
    variant.attributes = attrs  # reassigned, never mutated in place, so the session sees it


def validate(k: Kind, value: Any) -> Any:
    if value is None:
        return None
    if k.value_type == "number":
        try:
            number = float(value)
        except (TypeError, ValueError) as exc:
            raise ValueError(f"{k.field} takes a number, not {value!r}") from exc
        return int(number) if number.is_integer() else number
    if k.value_type == "bool":
        if isinstance(value, bool):
            return value
        raise ValueError(f"{k.field} takes true or false")
    if k.value_type == "structured":
        if not isinstance(value, dict):
            raise ValueError(f"{k.field} takes {{value, unit, form, ...}}")
        unit = value.get("unit")
        if unit not in quantity_service.UNITS and not (unit is None and value.get("set_contents")):
            raise ValueError("the unit is ml, g or pcs")
        if (value.get("form") or "single") not in quantity_service.FORMS:
            raise ValueError("the form is single, pack, set or refill")
        return value
    if not isinstance(value, str):
        raise ValueError(f"{k.field} takes text")
    text = value.strip()
    return text.lower()[:80] if k.storage is Storage.MARKED else text[:120]


# --------------------------------------------------------------------------- the key's slots

def identity_part(variant: Any) -> str:
    """`kind=value|kind=value` over the identity kinds the variant holds, sorted by kind name
    (plan W7): deterministic, stable under registry growth. The quantity has its own slot."""
    parts = []
    for a in of(variant):
        if a.identity != "always" or a.kind == "quantity":
            continue
        parts.append(f"{a.kind}={str(a.value).strip().lower()}")
    return "|".join(sorted(parts))


def quantity_slot(variant: Any) -> str:
    """The key's quantity slot: `100ml`, `50g`, `200pcs` (`quantity.quantity_key`), `n/a` for a
    category with no quantity (equals itself), `unknown` when nothing was read (never equal)."""
    if (getattr(variant, "vertical", None) or "", getattr(variant, "category", None) or "") in QUANTITY_NOT_APPLICABLE:
        return "n/a"
    return quantity_service.quantity_key(quantity_of(variant))


# --------------------------------------------------------------------------- the enrich guard

def columns_of(field_name: str) -> frozenset[str]:
    """The typed columns a ledger field materialises into: what a machine enricher must never
    write over while a decision on the field is effective. A JSON-stored kind guards nothing
    the enricher touches (the enrich loop reads columns only)."""
    if field_name == "name":
        return frozenset({"name"})
    if field_name in ("product_line", "product_line_id"):
        return frozenset({"product_line_id"})
    if field_name == "quantity":
        return QUANTITY_COLUMNS
    if field_name.startswith("attribute:"):
        try:
            k = kind_of(field_name[len("attribute:"):])
        except KeyError:
            return frozenset()
        if k.storage is Storage.QUANTITY:
            return QUANTITY_COLUMNS
        if k.storage is Storage.COLUMN:
            return frozenset({k.column})
    return frozenset()


#: Every field a decision can carry on a variant, to the columns it guards; derived, never typed.
GUARDED_COLUMNS: dict[str, frozenset[str]] = {
    "name": columns_of("name"), "product_line": columns_of("product_line"), "product_line_id": columns_of("product_line_id"),
    "quantity": QUANTITY_COLUMNS, "attribute": frozenset(),
    **{k.field: columns_of(k.field) for k in KINDS.values()},
}


def guarded_for(decided: dict[str, Any] | None) -> frozenset[str]:
    """The columns the given effective decisions (by field) protect from the machine."""
    out: builtins.set[str] = builtins.set()
    for field_name in (decided or {}):
        out |= columns_of(field_name)
    return frozenset(out)
