"""Natural keys: the portable address of every row a decision can name (spec §2). One grammar,
`build` and `parse` round-tripping, and `resolve` turning a key back into this host's row with
the detail as the authority; a key string is a label, never compared on the hot path.

Sources of truth: this module, `tests/test_natural_keys.py`, the spec §2 table. Delimiters:
`||` between a pair's sides, `/` inside a listing key, `|` inside a wording key; a component
that contains its level's delimiter is refused at build (`ValueError`), so a key is always
parseable. What it cost before: `propose --file` carried staging's row ids and could not load
on production (plan W13).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from sqlalchemy import select
from sqlalchemy.orm import Session, object_session

from app.models import Brand, Listing, ProductLine, ProductVariant, Redirect, Retailer, Shop, Suggestion, AttributeAlias, Precedent
from app.models.places import Place

PAIR = "||"


@dataclass
class Parked:
    """A key this host could not resolve to one row: the code and what it found."""

    code: str
    candidates: list[int] = field(default_factory=list)
    detail: str = ""


def _clean(component: str, delimiters: tuple[str, ...], what: str) -> str:
    if component is None or component == "":
        raise ValueError(f"{what} is empty")
    for d in delimiters:
        if d in component:
            raise ValueError(f"{what} {component!r} contains the delimiter {d!r}")
    return component


# --------------------------------------------------------------------------- build

def build(entity: Any) -> tuple[str, dict]:
    """The key and the resolution detail of a catalogue row."""
    if isinstance(entity, Brand):
        return f"brand:{_clean(entity.slug, (PAIR,), 'brand slug')}", {"name": entity.name}
    if isinstance(entity, ProductLine):
        brand = getattr(entity, "brand_row", None)
        return f"line:{entity.uid}", {
            "brand_slug": brand.slug if brand is not None else None, "slug": entity.slug, "key": entity.key,
            "rules_version": None, "name": entity.name,
        }
    if isinstance(entity, ProductVariant):
        rows = list(getattr(entity, "listings", None) or [])
        if not rows and object_session(entity) is not None and entity.id is not None:
            rows = list(object_session(entity).scalars(select(Listing).where(Listing.variant_id == entity.id)))
        listings = sorted(listing_key(l) for l in rows)
        detail: dict = {"listings": listings, "name": entity.name, "brand_slug": None}
        brand = getattr(entity, "brand_row", None)
        if brand is not None:
            detail["brand_slug"] = brand.slug
        if entity.gtin and entity.gtin_source != "merge":
            detail["gtin"] = entity.gtin
        return f"variant:{entity.uid}", detail
    if isinstance(entity, Listing):
        return listing_key(entity), {"retailer": _retailer_slug(entity), "code": _shop_of(entity).code, "sku": entity.source_sku}
    if isinstance(entity, AttributeAlias):
        vertical = _clean(entity.vertical, ("|", PAIR), "vertical")
        raw = _clean(entity.raw, (PAIR,), "wording")
        return f"wording:{vertical}|{raw}", {"kind": entity.kind}
    if isinstance(entity, Suggestion):
        sess = object_session(entity)
        model = {"brand": Brand, "line": ProductLine, "product": ProductVariant}[entity.level]
        left, right = (sess.get(model, entity.left_id), sess.get(model, entity.right_id)) if sess is not None else (None, None)
        if left is None or right is None:
            raise ValueError(f"suggestion {entity.id} has a missing side")
        return pair_key(entity.level, entity.left_id, entity.right_id, entity,
                        sides=(side_key(entity.level, left), side_key(entity.level, right)))
    if isinstance(entity, Place):
        return f"place:{_clean(entity.slug, (PAIR,), 'place slug')}", {"kind": entity.kind, "name": entity.name}
    if isinstance(entity, Precedent):
        return f"precedent:{_clean(entity.slug, (PAIR,), 'precedent slug')}", {"question": entity.question}
    raise TypeError(f"no natural key for {type(entity).__name__}")


def _shop_of(listing: Listing) -> Shop:
    """The listing's shop row, loaded through its session when the relationship is not yet."""
    shop = listing.shop
    if shop is None and listing.shop_id is not None:
        sess = object_session(listing)
        shop = sess.get(Shop, listing.shop_id) if sess is not None else None
    if shop is None:
        raise ValueError(f"listing {listing.id} has no shop")
    return shop


def _retailer_slug(listing: Listing) -> str:
    shop = _shop_of(listing)
    retailer = shop.retailer
    if retailer is None:
        sess = object_session(shop)
        retailer = sess.get(Retailer, shop.retailer_id) if sess is not None else None
    if retailer is None:
        raise ValueError(f"shop {shop.code} has no retailer")
    return retailer.slug


def listing_key(listing: Listing) -> str:
    retailer = _clean(_retailer_slug(listing), ("/", PAIR), "retailer slug")
    code = _clean(_shop_of(listing).code, ("/", PAIR), "shop code")
    sku = _clean(listing.source_sku, ("/", PAIR), "source sku")
    return f"listing:{retailer}/{code}/{sku}"


def side_key(level: str, row: Any) -> str:
    if level == "brand":
        return f"brand:{row.slug}"
    if level == "line":
        return f"line:{row.uid}"
    return f"variant:{row.uid}"


def pair_key(level: str, left_id: int, right_id: int, suggestion: Suggestion | None = None,
             sides: tuple[str, str] | None = None) -> tuple[str, dict]:
    """`pair:<level>:<keyA>||<keyB>` with the sides sorted as strings; the sides come from the
    caller (`sides`) when the rows are not loaded on the suggestion."""
    if sides is None:
        raise ValueError("pair_key needs the two side keys")
    a, b = sorted(sides)
    return f"pair:{level}:{a}{PAIR}{b}", {"level": level, "left": a, "right": b}


# --------------------------------------------------------------------------- parse

def parse(key: str) -> tuple[str, dict]:
    """The entity type and the parts of a key. `partition` on the first colon, then the entity's
    own grammar; a pair splits on `||` first."""
    head, sep, rest = key.partition(":")
    if not sep or not rest:
        raise ValueError(f"not a natural key: {key!r}")
    if head == "brand":
        return "brand", {"slug": rest}
    if head == "place":
        return "place", {"slug": rest}
    if head == "precedent":
        return "precedent", {"slug": rest}
    if head == "line":
        if rest.startswith("new:"):
            return "product_line", {"new": True, "slug": rest[4:]}
        return "product_line", {"uid": rest}
    if head == "variant":
        return "product_variant", {"uid": rest}
    if head == "listing":
        parts = rest.split("/")
        if len(parts) != 3:
            raise ValueError(f"a listing key is retailer/code/sku: {key!r}")
        return "listing", {"retailer": parts[0], "code": parts[1], "sku": parts[2]}
    if head == "wording":
        vertical, sep2, raw = rest.partition("|")
        if not sep2:
            raise ValueError(f"a wording key is vertical|raw: {key!r}")
        return "attribute_wording", {"vertical": vertical, "raw": raw}
    if head == "pair":
        level, sep2, sides = rest.partition(":")
        if not sep2 or PAIR not in sides:
            raise ValueError(f"a pair key is level:keyA||keyB: {key!r}")
        left, right = sides.split(PAIR, 1)
        return "suggestion", {"level": level, "left": left, "right": right}
    raise ValueError(f"unknown natural key type {head!r}")


# --------------------------------------------------------------------------- resolve

def resolve(db: Session, entity_type: str, key: str, detail: dict | None, *, mint: bool = False):
    """This host's row for a key, or `Parked`. The detail is the authority (spec §2): a variant
    resolves by uid, then its own barcode, then its listings agreeing through the merge chain;
    a line by uid, then by slug only when brand and key agree, then through `redirects`; a
    wording is minted when absent and `mint`; a pair's suggestion row likewise."""
    detail = detail or {}
    kind, parts = parse(key)
    if kind != entity_type:
        return Parked("KEY_TYPE_MISMATCH", detail=f"{key!r} is a {kind}, not a {entity_type}")
    if kind == "brand":
        row = db.scalar(select(Brand).where(Brand.slug == parts["slug"]))
        return row if row is not None else Parked("BRAND_MISSING", detail=parts["slug"])
    if kind == "place":
        row = db.scalar(select(Place).where(Place.slug == parts["slug"]))
        if row is None:
            redirect = db.scalar(select(Redirect).where(Redirect.kind == "place", Redirect.from_slug == parts["slug"]))
            if redirect is not None:
                row = db.scalar(select(Place).where(Place.slug == redirect.to_slug))
        return row if row is not None else Parked("PLACE_MISSING", detail=parts["slug"])
    if kind == "precedent":
        row = db.scalar(select(Precedent).where(Precedent.slug == parts["slug"]))
        return row if row is not None else Parked("PRECEDENT_MISSING", detail=parts["slug"])
    if kind == "product_line":
        return _resolve_line(db, parts, detail)
    if kind == "product_variant":
        return _resolve_variant(db, parts, detail)
    if kind == "listing":
        row = db.scalar(
            select(Listing).join(Shop, Listing.shop_id == Shop.id).join(Retailer, Shop.retailer_id == Retailer.id)
            .where(Retailer.slug == parts["retailer"], Shop.code == parts["code"], Listing.source_sku == parts["sku"])
        )
        return row if row is not None else Parked("LISTING_MISSING", detail=key)
    if kind == "attribute_wording":
        row = db.scalar(select(AttributeAlias).where(AttributeAlias.vertical == parts["vertical"], AttributeAlias.raw == parts["raw"]))
        if row is None and mint:
            from app.services.product_lines import canonical_attribute, display_attribute

            canonical = canonical_attribute(parts["raw"])
            row = AttributeAlias(vertical=parts["vertical"], raw=parts["raw"], canonical=canonical,
                                 display=display_attribute(canonical) if canonical else None, kind=detail.get("kind"))
            db.add(row)
            db.flush()
        return row if row is not None else Parked("WORDING_MISSING", detail=key)
    if kind == "suggestion":
        return _resolve_pair(db, parts, mint=mint)
    return Parked("KEY_TYPE_UNKNOWN", detail=key)


def _by_uid(db: Session, model, uid: str):
    try:
        import uuid as _uuid

        value = _uuid.UUID(str(uid))
    except (ValueError, AttributeError, TypeError):
        return None
    return db.scalar(select(model).where(model.uid == value))


def _resolve_line(db: Session, parts: dict, detail: dict):
    if parts.get("new"):
        slug = parts["slug"]
    else:
        row = _by_uid(db, ProductLine, parts["uid"])
        if row is not None:
            return row
        slug = detail.get("slug")
    if not slug:
        return Parked("LINE_MISSING", detail=parts.get("uid", ""))
    hit = db.scalar(select(ProductLine).where(ProductLine.slug == slug))
    if hit is None:
        redirect = db.scalar(select(Redirect).where(Redirect.kind == "product_line", Redirect.from_slug == slug))
        if redirect is not None:
            hit = db.scalar(select(ProductLine).where(ProductLine.slug == redirect.to_slug))
    if hit is None:
        return Parked("LINE_MISSING", detail=slug)
    if parts.get("new"):
        return hit
    brand_slug = detail.get("brand_slug")
    if brand_slug and hit.brand_row is not None and hit.brand_row.slug != brand_slug:
        return Parked("LINE_MISMATCH", [hit.id], detail=f"slug {slug} belongs to brand {hit.brand_row.slug}, not {brand_slug}")
    wanted_key = detail.get("key")
    if wanted_key and hit.key != wanted_key and not (detail.get("name") and hit.name == detail.get("name")):
        return Parked("LINE_MISMATCH", [hit.id], detail=f"slug {slug} holds key {hit.key!r}, not {wanted_key!r}")
    return hit


def _chase(db: Session, row: ProductVariant, limit: int = 8) -> ProductVariant:
    seen = 0
    while row.merged_into_id is not None and seen < limit:
        nxt = db.get(ProductVariant, row.merged_into_id)
        if nxt is None:
            break
        row, seen = nxt, seen + 1
    return row


def _resolve_variant(db: Session, parts: dict, detail: dict):
    row = _by_uid(db, ProductVariant, parts["uid"])
    if row is not None:
        return row
    gtin = detail.get("gtin")
    if gtin:
        rows = list(db.scalars(select(ProductVariant).where(ProductVariant.gtin == gtin, ProductVariant.gtin_source != "merge")))
        rows = [r for r in rows if r.gtin_source != "merge"] or list(db.scalars(select(ProductVariant).where(ProductVariant.gtin == gtin)))
        live = {_chase(db, r).id: _chase(db, r) for r in rows}
        if len(live) == 1:
            return next(iter(live.values()))
        if len(live) > 1:
            return Parked("VARIANT_SPLIT", sorted(live), detail=f"barcode {gtin} on {len(live)} live variants")
    found: dict[int, ProductVariant] = {}
    missing = 0
    for key in detail.get("listings") or []:
        listing = resolve(db, "listing", key, None)
        if isinstance(listing, Parked):
            missing += 1
            continue
        live = _chase(db, listing.variant)
        found[live.id] = live
    if len(found) == 1:
        return next(iter(found.values()))
    if len(found) > 1:
        return Parked("VARIANT_SPLIT", sorted(found), detail="the listings resolve to more than one live variant")
    return Parked("LISTING_MISSING", detail=f"{missing} listing key(s) not found, no barcode match")


def _resolve_pair(db: Session, parts: dict, *, mint: bool):
    level = parts["level"]
    model = {"brand": Brand, "line": ProductLine, "product": ProductVariant}.get(level)
    if model is None:
        return Parked("PAIR_LEVEL_UNKNOWN", detail=level)
    entity_type = {"brand": "brand", "line": "product_line", "product": "product_variant"}[level]
    sides = []
    for side in (parts["left"], parts["right"]):
        row = resolve(db, entity_type, side, None)
        if isinstance(row, Parked):
            return Parked("PAIR_SIDE_MISSING", detail=side)
        if level == "product":
            row = _chase(db, row)
        sides.append(row.id)
    left, right = min(sides), max(sides)
    row = db.scalar(select(Suggestion).where(Suggestion.level == level, Suggestion.left_id == left, Suggestion.right_id == right))
    if row is None and mint:
        from sqlalchemy.exc import IntegrityError

        savepoint = db.begin_nested()
        try:
            row = Suggestion(level=level, left_id=left, right_id=right, reason="decided", score=None,
                             detail={"why": "a person decided the pair"})
            db.add(row)
            savepoint.commit()
        except IntegrityError:
            savepoint.rollback()
            row = db.scalar(select(Suggestion).where(Suggestion.level == level, Suggestion.left_id == left, Suggestion.right_id == right))
    return row if row is not None else Parked("PAIR_MISSING", [left, right])
