"""One suggestion, opened up: everything a person needs to answer it without leaving the page.

Sources of truth: this module, `proposals.py` (the sheet and the approval), `routers/review.py`
(`GET /api/review/rows/{uid}`), `tests/test_review_detail.py`.

The sheet's row is the headline: what is being asked, about what, and how sure. That is the right
size for a list of twenty questions and far too small to answer a hard one. Rian, 18 Sep: *"What
product variants are involved? What attributes and properties are being merged? What conflicts are
there and how are we proposing to handle those? Eg: different barcodes, which one are we taking?"*

So this assembles, for whichever shape the row is: the things being folded with their variants, the
listings behind them with the shop's own text and a link to the source, the attributes that would
be carried, the conflicts a merge would have to resolve with what we propose to do about each, and
the decision ledger for the rows involved. It writes nothing.

A conflict is never resolved here. It is reported so a person can see it and decide; the writer's
own rules (a stated attribute that disagrees vetoes a merge) still hold at approval.
"""

from __future__ import annotations

import uuid as _uuid
from typing import Any

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

from app.models import Brand, Listing, ProductLine, ProductVariant, Proposal, ProposalPass, Retailer, Shop
from app.services.decisions import natural_keys

#: How many listings one row shows before it says how many more there are. A brand fold can name
#: several hundred; a person scanning for the odd one out does not need them all at once.
LISTING_CAP = 60


#: The kinds of suggestion a pass makes, as a person reads them. Rian, 18 Sep: an eyebrow on the
#: card, and "a growing list of types of decisions that we can maintain in the AI guidelines".
#: Keep this list and `docs/AI-REVIEW-GUIDELINES.md` saying the same thing.
CATEGORIES: dict[str, str] = {
    "combine-brands": "Combine brands",
    "combine-product-lines": "Combine product lines",
    "combine-product-variants": "Combine product variants",
    "keep-separate": "Confirm separate",
    "name-a-brand": "Name a brand",
    "name-a-product-line": "Name a product line",
    "move-a-product-variant": "Move a product variant",
    "set-an-attribute": "Set an attribute",
    "other": "Waiting for an answer",
}


def category_of(row: Proposal) -> str:
    """Which kind of suggestion this is. The card shows the words; this is the key."""
    value = row.corrected_value if row.corrected_value is not None else row.value
    if row.entity_type == "suggestion" and row.field == "decision":
        if isinstance(value, dict) and value.get("decision") == "separate":
            return "keep-separate"
        level = row.natural_key.split(":")[1] if row.natural_key.startswith("pair:") else ""
        return {"brand": "combine-brands", "line": "combine-product-lines",
                "product": "combine-product-variants"}.get(level, "other")
    if row.field == "merged_into":
        return "combine-product-variants"
    if row.field == "alias_of":
        return "combine-brands" if row.entity_type == "brand" else "combine-product-lines"
    if row.field == "product_line":
        return "move-a-product-variant"
    if row.field == "name":
        if row.entity_type == "brand":
            return "name-a-brand"
        # A header that absorbs other lines is a fold; one that only names a line is a naming.
        absorbs = ((row.natural_key_detail or {}).get("absorbs") or []) + ((row.detail or {}).get("absorbs_derived") or [])
        return "combine-product-lines" if absorbs else "name-a-product-line"
    if row.field.startswith("attribute:"):
        return "set-an-attribute"
    return "other"


def question_of(row: Proposal) -> str:
    """The question in a person's words, computed once so the compact row and the expanded view
    cannot disagree about what is being asked."""
    if row.entity_type == "suggestion" and row.field == "decision":
        level = row.natural_key.split(":")[1] if row.natural_key.startswith("pair:") else ""
        return {"brand": "Same brand?", "line": "Same product line?",
                "product": "Same product variant?"}.get(level, "Same, or separate?")
    if row.field == "merged_into":
        return "Same product variant?"
    if row.field == "product_line":
        return "Does this belong in this product line?"
    if row.field == "name" and row.entity_type == "product_line":
        return "Is this the product line's name?"
    if row.field == "name" and row.entity_type == "brand":
        return "Is this the brand's name?"
    if row.field == "alias_of":
        return "Is this the same thing?"
    if row.field.startswith("attribute:"):
        return "Is this attribute right?"
    return "Waiting for an answer"


def _shop_of(db: Session, listing: Listing, shops: dict, retailers: dict) -> dict:
    shop = shops.get(listing.shop_id)
    retailer = retailers.get(shop.retailer_id) if shop is not None else None
    return {
        "key": natural_keys.listing_key(listing),
        "variant_uid": None,  # filled by the caller, which knows the variant it asked for
        "shop": shop.code if shop else None,
        "shop_name": shop.name if shop else None,
        "airport": shop.iata if shop else None,
        "retailer": retailer.name if retailer else None,
        "listed_brand": listing.listed_brand,
        "listed_name": listing.listed_name,
        "listed_variant": listing.listed_variant,
        "listed_quantity_text": listing.listed_quantity_text,
        "gtin": listing.listed_gtin,
        # The shop's own page. It is the only way to check a reading against the source, so a
        # listing without one says so rather than rendering a dead link.
        "url": listing.url,
    }


def _variant_out(db: Session, v: ProductVariant, lines: dict) -> dict:
    line = lines.get(v.product_line_id)
    return {
        "uid": str(v.uid), "name": v.name, "gtin": v.gtin,
        "quantity_ml": v.quantity_ml, "quantity_state": v.quantity_state,
        "attributes": v.attributes or {},
        "line_name": line.name if line is not None else None,
        "product_line_key": f"line:{line.uid}" if line is not None else None,
    }


def _conflicts(variants: list[dict]) -> list[dict]:
    """What a merge would have to resolve, and what we propose. Reported, never applied.

    Rian's own example is the first one: two variants with different barcodes cannot both be right,
    and a person needs to see WHICH before the merge happens, not after."""
    out: list[dict] = []
    if len(variants) < 2:
        return out

    def spread(field: str, label: str, what: str) -> None:
        seen: dict[Any, list[str]] = {}
        for v in variants:
            value = v.get(field)
            if value in (None, "", {}):
                continue
            seen.setdefault(value, []).append(v["name"])
        if len(seen) > 1:
            out.append({"field": field, "label": label,
                        "values": [{"value": str(k), "on": names} for k, names in seen.items()],
                        "proposed": None, "what": what})

    spread("gtin", "Barcode", "Two barcodes name two products. Nothing is chosen here: a merge "
                              "that would drop one is refused until a person says which is right.")
    spread("quantity_ml", "Quantity", "The quantity is the unit a price is compared on, so two "
                                      "values would compare different things.")
    kinds: dict[str, dict[str, list[str]]] = {}
    for v in variants:
        for kind, value in (v.get("attributes") or {}).items():
            if value in (None, "", {}):
                continue
            kinds.setdefault(kind, {}).setdefault(str(value), []).append(v["name"])
    for kind, values in kinds.items():
        if len(values) > 1:
            out.append({"field": f"attribute:{kind}", "label": kind.replace("_", " ").title(),
                        "values": [{"value": k, "on": names} for k, names in values.items()],
                        "proposed": None,
                        "what": "Two stated values for one attribute. A merge is vetoed while they "
                                "disagree; keeping them apart is the safe answer."})
    return out


def _fold_key(text: str | None) -> str:
    """Case and accents folded, for spotting two product lines that carry the same name."""
    import unicodedata

    if not text:
        return ""
    return unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode().lower().strip()


def consequences(db: Session, row: Proposal, subjects: list[dict]) -> dict[str, Any]:
    """What answering unlocks, as a SHAPE and never as the questions themselves.

    Rian, 18 Sep: *"I'm nervous to click confirm because I dont want to create a mess, but I do want
    to see what comes next."* Showing the queued decisions would bias the answer he has not made
    yet; showing how many there will be and what kind does not. So this counts and characterises,
    and names nothing it would be proposing.
    """
    blocked = db.scalar(select(func.count(Proposal.id)).where(
        Proposal.brand_slug.in_([s.get("slug") for s in subjects if s.get("slug")] or [""]),
        Proposal.status == "open", Proposal.id != row.id)) or 0
    out: dict[str, Any] = {"if_confirmed": [], "if_separate": [], "blocked_now": int(blocked)}

    if row.natural_key.startswith("pair:brand:") and len(subjects) == 2:
        value = row.corrected_value if row.corrected_value is not None else row.value
        keep = (value or {}).get("survivor") if isinstance(value, dict) else None
        winner = next((s for s in subjects if s["key"] == keep), subjects[0])
        loser = next((s for s in subjects if s["key"] != winner["key"]), subjects[-1])
        w = db.scalar(select(Brand).where(Brand.slug == winner.get("slug")))
        l = db.scalar(select(Brand).where(Brand.slug == loser.get("slug")))
        collisions = 0
        if w is not None and l is not None:
            theirs = {_fold_key(r.name) for r in db.scalars(
                select(ProductLine).where(ProductLine.brand_id == w.id, ProductLine.alias_of_id.is_(None)))}
            collisions = sum(1 for r in db.scalars(
                select(ProductLine).where(ProductLine.brand_id == l.id, ProductLine.alias_of_id.is_(None)))
                if _fold_key(r.name) in theirs)
        out["if_confirmed"] = [
            {"what": f"{loser['variants']:,} product variants and {loser['lines']:,} product lines move to "
                     f"{winner['name']}, and its {loser['listings']:,} listings with them.", "count": None},
            {"what": f"about {collisions} product line questions follow, where a line on each side carries the "
                     f"same name. They do not exist yet: the next pass writes them.", "count": collisions},
            {"what": "expect at least one of those to set a new precedent, which is the slow kind.",
             "count": None} if collisions else None,
        ]
        out["if_separate"] = [
            {"what": "nothing moves, and the two stay two brands.", "count": None},
            {"what": "the pair is never proposed as one again.", "count": None},
        ]
    else:
        moved = sum(s["variants"] for s in subjects[1:]) if len(subjects) > 1 else 0
        out["if_confirmed"] = [{"what": f"{moved:,} product variants move." if moved else "the value is set.",
                                "count": moved or None}]
        out["if_separate"] = [{"what": "nothing changes, and this is not proposed again.", "count": None}]
    if blocked:
        note = {"what": f"{blocked} other suggestion(s) on this brand become answerable.", "count": int(blocked)}
        out["if_confirmed"].append(note)
        out["if_separate"].append(note)
    out["if_confirmed"] = [x for x in out["if_confirmed"] if x]
    return out


def read(db: Session, uid: str) -> dict[str, Any]:
    """Everything behind one suggestion. Returns `{}` when no row answers the uid."""
    try:
        row = db.scalar(select(Proposal).where(Proposal.uid == _uuid.UUID(str(uid))))
    except (ValueError, AttributeError, TypeError):
        row = None
    if row is None:
        return {}
    shops = {s.id: s for s in db.scalars(select(Shop))}
    retailers = {r.id: r for r in db.scalars(select(Retailer))}
    pass_row = db.get(ProposalPass, row.pass_id)

    # What the question is ABOUT: a pair's two sides, a header's absorbed lines, or the row's own
    # entity. Each side carries its own shape so a person sees the size of what they are joining.
    subject_keys: list[str] = []
    detail = row.natural_key_detail or {}
    if row.entity_type == "suggestion":
        subject_keys = [k for k in (detail.get("left"), detail.get("right")) if k]
        if not subject_keys:
            try:
                _, parts = natural_keys.parse(row.natural_key)
                subject_keys = [parts["left"], parts["right"]]
            except (ValueError, KeyError):
                subject_keys = []
    else:
        # Deduped: the loader DERIVES the lines a header absorbs, so a file that also named them
        # produced the same line twice and the card listed "One Million Gold" under itself.
        subject_keys = [row.natural_key]
        for key in (detail.get("absorbs") or []) + ((row.detail or {}).get("absorbs_derived") or []):
            if key and key not in subject_keys:
                subject_keys.append(key)

    # What happens to each side, which is the thing a person is actually agreeing to. Listing a
    # name and a count said nothing: "Million Gold 4 variants / One Million Gold 2 variants" does
    # not say which survives (rian, 18 Sep).
    category = category_of(row)
    keeps = row.entity_type != "suggestion"  # a header keeps its own row; a pair names a survivor
    survivor_key = None
    if row.entity_type == "suggestion":
        value = row.corrected_value if row.corrected_value is not None else row.value
        survivor_key = (value or {}).get("survivor") if isinstance(value, dict) else None
        if survivor_key is None and subject_keys:
            survivor_key = subject_keys[0]
    winner_key = survivor_key if row.entity_type == "suggestion" else (subject_keys[0] if subject_keys else None)
    survivor_name: list[str] = []
    if winner_key:
        try:
            kind, parts = natural_keys.parse(winner_key)
            if kind == "brand":
                found = db.scalar(select(Brand).where(Brand.slug == parts.get("slug")))
            elif kind == "product_line" and parts.get("uid"):
                found = db.scalar(select(ProductLine).where(ProductLine.uid == _uuid.UUID(parts["uid"])))
            elif kind == "product_variant" and parts.get("uid"):
                found = db.scalar(select(ProductVariant).where(ProductVariant.uid == _uuid.UUID(parts["uid"])))
            else:
                found = None
            if found is not None:
                survivor_name.append(found.name)
        except (ValueError, KeyError):
            pass

    subjects: list[dict] = []
    variant_rows: list[ProductVariant] = []

    def becomes(key: str, first: bool) -> str:
        """What happens to this side, said plainly.

        It NAMES the survivor rather than pointing at it: "becomes the one above" was wrong the
        first time a pair listed its loser first, which is most of them, because the sides are
        sorted by key and not by which one wins.
        """
        if category == "keep-separate":
            return "stays separate"
        winner = survivor_key if row.entity_type == "suggestion" else (subject_keys[0] if subject_keys else None)
        if key == winner:
            return "stays as it is"
        name = survivor_name[0] if survivor_name else None
        return f"becomes {name}" if name else "is folded into the other"
    for key in subject_keys:
        try:
            kind, parts = natural_keys.parse(key)
        except ValueError:
            continue
        if kind == "brand":
            b = db.scalar(select(Brand).where(Brand.slug == parts.get("slug")))
            if b is None:
                subjects.append({"key": key, "kind": kind, "name": None, "variants": 0, "listings": 0, "becomes": "not on this host"})
                continue
            held = list(db.scalars(select(ProductVariant).where(ProductVariant.brand_id == b.id,
                                                                ProductVariant.merged_into_id.is_(None))))
            n_listings = db.scalar(select(func.count(Listing.id)).join(
                ProductVariant, ProductVariant.id == Listing.variant_id).where(
                ProductVariant.brand_id == b.id, Listing.ignored_at.is_(None))) or 0
            subjects.append({"key": key, "kind": kind, "name": b.name, "slug": b.slug, "becomes": becomes(key, not subjects),
                             "variants": len(held), "listings": int(n_listings),
                             "lines": int(db.scalar(select(func.count(func.distinct(ProductVariant.product_line_id)))
                                                    .where(ProductVariant.brand_id == b.id,
                                                           ProductVariant.merged_into_id.is_(None))) or 0)})
            variant_rows.extend(held[:LISTING_CAP])
        elif kind == "product_line":
            line = db.scalar(select(ProductLine).where(ProductLine.uid == _uuid.UUID(parts["uid"]))) \
                if parts.get("uid") else None
            if line is None:
                subjects.append({"key": key, "kind": kind, "name": parts.get("slug"), "variants": 0, "listings": 0, "becomes": "would be created"})
                continue
            held = list(db.scalars(select(ProductVariant).where(ProductVariant.product_line_id == line.id,
                                                                ProductVariant.merged_into_id.is_(None))))
            subjects.append({"key": key, "kind": kind, "name": line.name, "slug": line.slug, "becomes": becomes(key, not subjects),
                             "variants": len(held), "listings": 0})
            variant_rows.extend(held)
        elif kind == "product_variant":
            v = db.scalar(select(ProductVariant).where(ProductVariant.uid == _uuid.UUID(parts["uid"]))) \
                if parts.get("uid") else None
            if v is not None:
                subjects.append({"key": key, "kind": kind, "name": v.name, "variants": 1, "listings": 0, "becomes": becomes(key, not subjects)})
                variant_rows.append(v)

    lines = {l.id: l for l in db.scalars(select(ProductLine))} if variant_rows else {}
    variants = [_variant_out(db, v, lines) for v in variant_rows]

    listings: list[dict] = []
    total = 0
    if variant_rows:
        ids = [v.id for v in variant_rows]
        total = db.scalar(select(func.count(Listing.id)).where(Listing.variant_id.in_(ids),
                                                               Listing.ignored_at.is_(None))) or 0
        uid_of = {v.id: str(v.uid) for v in variant_rows}
        for l in db.scalars(select(Listing).where(Listing.variant_id.in_(ids), Listing.ignored_at.is_(None))
                            .order_by(Listing.listed_brand, Listing.listed_name).limit(LISTING_CAP)):
            listings.append({**_shop_of(db, l, shops, retailers), "variant_uid": uid_of.get(l.variant_id)})

    merging_variants = row.field == "merged_into" or (
        row.entity_type == "suggestion" and row.natural_key.startswith("pair:product:"))
    # The product lines involved, which is the level a brand fold is really about: 54 variants are
    # hard to read, 24 product lines are not. Each carries its variants so it can be opened.
    lines_out: list[dict] = []
    if variant_rows:
        seen_line: dict[int, dict] = {}
        for v in variant_rows:
            line = lines.get(v.product_line_id) if v.product_line_id else None
            key = v.product_line_id or 0
            slot = seen_line.get(key)
            if slot is None:
                slot = {"key": f"line:{line.uid}" if line is not None else "line:none",
                        "name": line.name if line is not None else "no product line",
                        "slug": line.slug if line is not None else None,
                        "brand": (db.get(Brand, line.brand_id).name if line is not None and line.brand_id else None),
                        "variant_uids": [], "listings": 0}
                seen_line[key] = slot
                lines_out.append(slot)
            slot["variant_uids"].append(str(v.uid))
        counts = dict(db.execute(select(Listing.variant_id, func.count(Listing.id))
                                 .where(Listing.variant_id.in_([v.id for v in variant_rows]),
                                        Listing.ignored_at.is_(None))
                                 .group_by(Listing.variant_id)).all())
        by_uid = {str(v.uid): v.id for v in variant_rows}
        for slot in lines_out:
            slot["listings"] = sum(int(counts.get(by_uid.get(u, 0), 0)) for u in slot["variant_uids"])
        lines_out.sort(key=lambda r: (-len(r["variant_uids"]), r["name"] or ""))

    ledger: list[dict] = []
    if row.entity_id is not None:
        from app.services import proposals as review

        ledger = review.decisions_for(db, row.entity_type, row.entity_id, 50)

    return {
        "uid": str(row.uid), "question": question_of(row), "field": row.field,
        "category": category, "category_label": CATEGORIES.get(category, CATEGORIES["other"]),
        "entity_type": row.entity_type, "natural_key": row.natural_key,
        "status": row.status, "confidence": float(row.confidence) if row.confidence is not None else None,
        "reason": row.reason, "pass_name": pass_row.name if pass_row else None,
        "is_brand_fold": row.entity_type == "suggestion" and row.natural_key.startswith("pair:brand:"),
        "subjects": subjects,
        "variants": variants,
        "listings": listings,
        "listings_total": int(total),
        "listings_shown": len(listings),
        # Only where variants actually BECOME ONE. A brand or a product line fold moves variants
        # and keeps them distinct, so their different barcodes and sizes are the catalogue working,
        # not a conflict; listing them there buried the real ones in noise.
        "lines": lines_out,
        "consequences": consequences(db, row, subjects),
        "conflicts": _conflicts(variants) if merging_variants else [],
        "merges_variants": merging_variants,
        "ledger": ledger,
    }
