"""The keying call: what ingest and rederive ask before a product is keyed (Stream M).

Sources of truth: this module, `normalize.match_key` (identity rules v4), `lines.py`,
`quantity.py`, `overrides.py` (the decided layer, read first),
`models/catalog.py` (Brand.canonical_id, VariationAlias, IDENTITY_RULES_VERSION),
`tests/test_keying.py`. Identity rules v3 key a product on house | line | variation |
size, with a form marker for a set, a refill or a pack, so "automatic matching = same
house, same line, same canonical variation, same size, no barcode disagreement" (rian, 12
Sep) is the key itself: an unknown variation keys apart and is only ever a suggestion.

Two things need the database before the pure key can be computed: the house (the brand
row with its alias followed, so a confirmed "Paco Rabanne is Rabanne" reaches every key of
both spellings) and the canonical variation (the rule's reading, unless a person mapped
that wording elsewhere in `variation_aliases`). Both are read into `Maps` once and reused:
a collection keys thousands of listings, a rederive every product. `maps_for()` caches per
database for a few minutes, long enough for a run and short enough that a confirm in the
merge session reaches the next one; the merge routes call `invalidate()` after writing.
"""

from __future__ import annotations

import time
from dataclasses import dataclass, field

from sqlalchemy import inspect, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.models import Brand, Product, ProductLine, VariationAlias
from app.models.accounts import Override
from app.services import lines
from app.services import quantity as quantity_service
from app.services.collectors.base import RawListing
from app.services.normalize import brand_key, match_key
from app.services.taxonomy import classify, vertical_of

CACHE_SECONDS = 300


@dataclass
class Maps:
    """What the key needs from the database, read once: brand rows by id and by slug, and
    the variation aliases a person or the backfill recorded, by (vertical, raw wording)."""

    brands: dict[int, Brand] = field(default_factory=dict)
    by_slug: dict[str, Brand] = field(default_factory=dict)
    aliases: dict[tuple[str, str], str] = field(default_factory=dict)
    #: The kind a person or the backfill recorded for a wording, by (vertical, raw).
    alias_kinds: dict[tuple[str, str], str] = field(default_factory=dict)
    lines: dict[int, ProductLine] = field(default_factory=dict)
    lines_by_key: dict[tuple[int, str], ProductLine] = field(default_factory=dict)
    #: The decided layer (Stream L): a product's overrides by id then field (`name`,
    #: `line_id`, `variation`, `quantity`); every rekey reads it before the rules.
    decided: dict[int, dict[str, Override]] = field(default_factory=dict)
    loaded_at: float = 0.0

    def house(self, brand_id: int | None = None, brand: str | None = None) -> Brand | None:
        """The canonical brand row for a product's brand id, else for a spelling."""
        row = lines.resolve_alias(self.brands, brand_id) if brand_id else None
        if row is None and brand:
            found = self.by_slug.get(brand_key(brand).replace(" ", "-"))
            row = lines.resolve_alias(self.brands, found.id) if found else None
        return row

    def line(self, house: Brand | None, computed_key: str) -> ProductLine | None:
        """The canonical line row for a house and a computed line key: the row itself, or
        the line it was folded into (a line alias), or None when no row exists yet."""
        if house is None:
            return None
        row = self.lines_by_key.get((house.id, computed_key))
        return lines.resolve_alias(self.lines, row.id) if row else None

    def remember_line(self, row: ProductLine) -> None:
        self.lines[row.id] = row
        self.lines_by_key[(row.brand_id, row.key)] = row

    def variation(self, name: str | None, vertical: str | None, *, category: str | None = None) -> str:
        """The canonical variation of a name: the alias table's word for its wording when a
        row exists (a person's decision or the seeded rule), else the rule's. The category
        reaches the rule (a Makeup shade, Stream L)."""
        raw, rule = lines.variation_of(name, vertical, category=category)
        if not raw:
            return ""
        return self.aliases.get((vertical or "", raw), rule)

    def variation_kind(self, name: str | None, vertical: str | None, *, category: str | None = None) -> str:
        """The kind of a name's variation: the alias row's when a person or the backfill set
        one, else the rule's (`lines.variation_kind_of`)."""
        raw, _ = lines.variation_of(name, vertical, category=category)
        if not raw:
            return ""
        return self.alias_kinds.get((vertical or "", raw)) or lines.variation_kind_of(name, vertical, category=category)


_cache: dict[int, Maps] = {}


def load_maps(db: Session) -> Maps:
    maps = Maps(loaded_at=time.monotonic())
    # Inspect through the session's own connection: an inspector on the engine borrows a
    # second connection, and on a single-connection pool (the tests' SQLite) that is the
    # same connection, whose release rolled back the caller's unflushed alias.
    tables = inspect(db.connection()).get_table_names()
    if "brands" in tables:
        for row in db.scalars(select(Brand)):
            maps.brands[row.id] = row
            maps.by_slug[row.slug] = row
    if "variation_aliases" in tables:
        for alias in db.scalars(select(VariationAlias)):
            maps.aliases[(alias.vertical, alias.raw)] = alias.canonical
            if getattr(alias, "kind", None):
                maps.alias_kinds[(alias.vertical, alias.raw)] = alias.kind
    if "product_lines" in tables:
        for row in db.scalars(select(ProductLine)):
            maps.remember_line(row)
    if "overrides" in tables:
        for row in db.scalars(select(Override).where(Override.entity_type == "product")):
            try:
                maps.decided.setdefault(int(row.entity_key), {})[row.field] = row
            except ValueError:
                continue
    return maps


def maps_for(db: Session) -> Maps:
    """The maps for this database, cached for CACHE_SECONDS; `invalidate()` after a write
    that changes a house or a variation alias."""
    key = id(db.get_bind())
    maps = _cache.get(key)
    if maps is None or time.monotonic() - maps.loaded_at > CACHE_SECONDS:
        maps = load_maps(db)
        _cache[key] = maps
    return maps


def invalidate() -> None:
    _cache.clear()


def house_words(house: Brand | None) -> str | None:
    """The house as the key names it: the canonical row's slug as words, so a renamed house
    keeps its key ("ysl" stays "ysl" when the name becomes Yves Saint Laurent)."""
    return house.slug.replace("-", " ") if house is not None else None


def line_for(db: Session, maps: Maps, house: Brand | None, name: str | None, brand: str | None,
             vertical: str | None, *, create: bool) -> ProductLine | None:
    """The canonical line a name belongs to under its house: found by the computed key,
    followed through a line alias; created on first sight when `create` (ingest, like the
    brand row), inside a savepoint so a concurrent collector creating the same line does
    not poison the session. None without a house."""
    if house is None or not name:
        return None
    computed = lines.line_key(name, brand=brand, house=house_words(house), vertical=vertical)
    row = maps.line(house, computed)
    if row is not None or not create:
        return row
    slug = lines.line_slug(house.slug, computed)[:230]
    savepoint = db.begin_nested()
    try:
        row = ProductLine(brand_id=house.id, key=computed, slug=slug,
                          name=lines.display_line_name(computed, [name], house.name,
                                                       brand_words=lines.brand_words_of(brand, house.name),
                                                       vertical=vertical)[:200])
        db.add(row)
        savepoint.commit()
    except IntegrityError:
        savepoint.rollback()
        row = db.scalar(select(ProductLine).where(ProductLine.brand_id == house.id, ProductLine.key == computed))
        if row is None:
            return None
    maps.remember_line(row)
    return lines.resolve_alias(maps.lines, row.id) or row


def listing_key(db: Session, raw: RawListing, attributes: dict,
                quantity: quantity_service.Quantity | None = None) -> tuple[str, dict, int | None]:
    """The key, the attributes and the line a listing resolves under: the collector's
    attributes (the concentration as parsed, the raw record) plus the canonical
    `variation`; the v3 key computed with the house, the line (an alias followed to its
    canonical) and that variation; and the line's id for a product created from it."""
    maps = maps_for(db)
    category = classify(raw.name, raw.brand, raw.feed_categories)
    vertical = raw.vertical or vertical_of(category)
    variation = maps.variation(raw.name, vertical, category=category)
    attributes = dict(attributes)
    if variation:
        attributes["variation"] = variation
        kind = maps.variation_kind(raw.name, vertical, category=category)
        if kind:
            attributes["variation_kind"] = kind
    house = maps.house(brand=raw.brand)
    line = line_for(db, maps, house, raw.name, raw.brand, vertical, create=True)
    key = match_key(raw.brand, raw.name, raw.size_ml, vertical=vertical, house=house_words(house),
                    variation=variation, line=line.key if line else None, quantity=quantity)
    return key, attributes, line.id if line else None


def keyed_name(product: Product, maps: Maps) -> str:
    """The spelling every name-derived key part is computed from: the collected spelling
    kept in a `name` override's `collected_value` when a person renamed the product, else the
    name itself. A typed name is display only for the key: keying from it once lost the
    concentration ("1 Million" keys `rabanne|1-million||100ml`) and the next collection
    created a new product beside the renamed one."""
    row = maps.decided.get(product.id, {}).get("name")
    if row is not None and row.collected_value:
        return str(row.collected_value)
    return product.name


def product_quantity(product: Product, maps: Maps) -> quantity_service.Quantity:
    """A stored product's quantity, override first: a decided `quantity` override; else the
    v4 columns when a backfill or ingest wrote them; else the name parsed with the stored
    v3 pair as the hint, which is exactly what `backfill quantities` writes."""
    row = maps.decided.get(product.id, {}).get("quantity")
    if row is not None and isinstance(row.value, dict):
        v = row.value
        return quantity_service.from_stored(
            v.get("value"), v.get("unit"), pack_count=v.get("pack_count"), pack_unit_value=v.get("pack_unit_value"),
            form=v.get("form") or "single", set_contents=v.get("set_contents"),
            state="stated" if v.get("value") is not None or v.get("set_contents") else "none",
        )
    if getattr(product, "quantity_state", None):
        return quantity_service.from_stored(
            product.quantity_value, product.quantity_unit, pack_count=product.pack_count,
            pack_unit_value=product.pack_unit_value, form=product.form, set_contents=product.set_contents,
            state=product.quantity_state,
        )
    hint = None
    if product.size_value is not None and product.size_unit:
        hint = (product.size_value, product.size_unit)
    elif product.size_ml:
        hint = (product.size_ml, "ml")
    return quantity_service.parse_quantity(keyed_name(product, maps), hint=hint, category=product.category)


def product_key(product: Product, maps: Maps) -> tuple[str, dict]:
    """The v4 key and attributes of a stored product under the current rules (rederive
    and the alias routes), the decided layer read first: a `line_id` override names the
    line, a `variation` override the variation, a `quantity` override the slot, and a
    `name` override's collected spelling is what the rules read. The line row is followed
    through any alias, so a confirmed line alias reaches every key under it."""
    decided = maps.decided.get(product.id, {})
    name = keyed_name(product, maps)
    if "variation" in decided and decided["variation"].value is not None:
        variation = str(decided["variation"].value)
    else:
        variation = maps.variation(name, product.vertical, category=product.category)
    attributes = {k: v for k, v in (product.attributes or {}).items() if k not in ("variation", "variation_kind")}
    if variation:
        attributes["variation"] = variation
        kind = maps.variation_kind(name, product.vertical, category=product.category)
        if kind:
            attributes["variation_kind"] = kind
    house = maps.house(product.brand_id, product.brand)
    line_id = product.line_id
    if "line_id" in decided and decided["line_id"].value is not None:
        line_id = int(decided["line_id"].value)
    line = lines.resolve_alias(maps.lines, line_id) if line_id else None
    key = match_key(product.brand, name, product.size_ml, vertical=product.vertical,
                    house=house_words(house), variation=variation, line=line.key if line else None,
                    quantity=product_quantity(product, maps))
    return key, attributes
