"""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 v3), `lines.py`,
`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.services import lines
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)
    lines: dict[int, ProductLine] = field(default_factory=dict)
    lines_by_key: dict[tuple[int, str], ProductLine] = 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) -> 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."""
        raw, rule = lines.variation_of(name, vertical)
        if not raw:
            return ""
        return self.aliases.get((vertical or "", raw), rule)


_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 "product_lines" in tables:
        for row in db.scalars(select(ProductLine)):
            maps.remember_line(row)
    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) -> 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)
    vertical = raw.vertical or vertical_of(classify(raw.name, raw.brand, raw.feed_categories))
    variation = maps.variation(raw.name, vertical)
    attributes = dict(attributes)
    if variation:
        attributes["variation"] = variation
    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)
    return key, attributes, line.id if line else None


def product_key(product: Product, maps: Maps) -> tuple[str, dict]:
    """The v3 key and attributes of a stored product under the current rules (rederive
    and the alias routes): its line row, followed through any alias, names the line part,
    so a confirmed line alias reaches every key under it."""
    variation = maps.variation(product.name, product.vertical)
    attributes = {k: v for k, v in (product.attributes or {}).items() if k != "variation"}
    if variation:
        attributes["variation"] = variation
    house = maps.house(product.brand_id, product.brand)
    line = lines.resolve_alias(maps.lines, product.line_id) if product.line_id else None
    key = match_key(product.brand, product.name, product.size_ml, vertical=product.vertical,
                    house=house_words(house), variation=variation, line=line.key if line else None)
    return key, attributes
