"""Everything waiting to be joined or kept apart, at every level (Stream K10.3).

Sources of truth: this module, `models/catalog.py` (`Suggestion`), `models/decisions.py`
(`Proposal`), `services/merge_desk.py` (where a pair is acted on), `services/proposals.py` (where
a value is), `tests/test_folding_work.py`.

Rian, 17 Sep, reading `/review`: *"It seems like it focuses on brand folding, but what about
product line decisions? I hope we haven't missed the product line decisions because that's where
I think a lot of the folding will take place... eg the folding shades or misspellings, etc. And
then there is option folding/splitting too I think?"*

Nothing was missed -- the line pairs, the wording aliases and the variant merges were all queued
-- but the sheet is arranged by BRAND, so a brand's sheet is what he saw, and the rest sat on the
desk and in the queue with no one page saying how much of each was waiting. This module is that
page's reader.

**Two kinds of thing wait, and they are counted separately, never added twice.**

* A **pair**: one `suggestions` row at a level, waiting for Confirm same or Keep separate. Some
  carry a rule's reading of them as a `suggestion` proposal, which is what puts a readable reason
  and its evidence on a sheet; the pair is still the thing that is decided.
* A **value**: a proposal on a row's own field -- a brand's spellings, a variant's attribute, what
  a marked wording means.

A `suggestion` proposal is therefore counted as a pair with a reason, NOT as a second row beside
the pair it reads. Adding the two tables would have printed 3,658 waiting at line level where
2,572 pairs wait.

Read-only: every function here writes nothing. The deciding is done where it already was, on the
brand's sheet or on the desk, and this page links to it.
"""

from __future__ import annotations

from collections import defaultdict
from typing import Any

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

from app.models import Brand, ProductLine, ProductVariant, Proposal, ProposalPass, Suggestion
from app.models import AttributeAlias

#: The four levels, in the order the tab shows them: the widest thing first, because a brand fold
#: moves every product line under it and a line fold moves every variant under that.
LEVELS = ("brand", "product_line", "attribute", "product_variant")

#: The `suggestions.level` word for a level of ours. `attribute` has no pair queue: two wordings
#: are joined by one of them being given the other's meaning, never by a pair.
_PAIR_LEVEL = {"brand": "brand", "product_line": "line", "product_variant": "product"}

LABEL = {"brand": "Brands", "product_line": "Product lines", "attribute": "Attributes and wordings",
         "product_variant": "Product variants"}

WHAT_IT_CHANGES = {
    "brand": "The two brand rows become one: the alias points at the other, its product lines and "
             "every variant under them move across, and every later collection of that spelling "
             "lands on the one row.",
    "product_line": "The two product lines become one under their brand: the variants move to it, "
                    "the old address forwards, and the page a shopper searches for is one page.",
    "product_variant": "The two product variants merge: the listings and medals move to the "
                       "survivor, the loser keeps its row and forwards, and the two shops' prices "
                       "become one comparison.",
    "attribute": "The word stops telling two variants apart: it is read as the same value on both, "
                 "so they meet on the key instead of sitting on two pages.",
}

#: The question each level answers, in a person's words.
ASKS = {
    "brand": "Is this one company, spelled two ways?",
    "product_line": "Is this one product a shopper would search for, named two ways? "
                    "A misspelling, a word order, a shade or a size in the name.",
    "attribute": "What does this word the shop wrote actually mean?",
    "product_variant": "Is this the same bottle at two shops?",
}


def _pass_names(db: Session) -> dict[int, ProposalPass]:
    return {p.id: p for p in db.scalars(select(ProposalPass))}


def counts(db: Session) -> list[dict[str, Any]]:
    """Per level: the pairs waiting, how many of those a rule has already read and put a reason
    on, the values waiting, and the total. The first thing on the tab, so a person sees where the
    work is before reading a single row."""
    pairs: dict[str, int] = {}
    for level, n in db.execute(select(Suggestion.level, func.count(Suggestion.id))
                               .where(Suggestion.is_open()).group_by(Suggestion.level)):
        pairs[level] = n
    proposed: dict[str, int] = defaultdict(int)
    values: dict[str, int] = defaultdict(int)
    for row in db.scalars(select(Proposal).where(Proposal.status == "open")):
        level = _level_of(row)
        if level is None:
            continue
        if row.entity_type == "suggestion":
            proposed[level] += 1
        else:
            values[level] += 1
    out = []
    for level in LEVELS:
        pair_level = _PAIR_LEVEL.get(level)
        n_pairs = pairs.get(pair_level, 0) if pair_level else 0
        out.append({"level": level, "label": LABEL[level], "asks": ASKS[level],
                    "changes": WHAT_IT_CHANGES[level],
                    "pairs": n_pairs, "pairs_with_a_reason": min(proposed[level], n_pairs),
                    "values": values[level], "total": n_pairs + values[level]})
    return out


def _level_of(row: Proposal) -> str | None:
    """Which level a proposal belongs to. A pair proposal takes its pair's level from the key
    (`pair:<level>:...`), which the loader wrote and the sheet reads the same way."""
    if row.entity_type == "suggestion":
        parts = (row.natural_key or "").split(":")
        pair_level = parts[1] if len(parts) > 2 and parts[0] == "pair" else None
        return {"brand": "brand", "line": "product_line", "product": "product_variant"}.get(pair_level)
    if row.entity_type == "brand":
        return "brand"
    if row.entity_type == "product_line":
        return "product_line"
    if row.entity_type == "attribute_wording":
        return "attribute"
    if row.entity_type == "product_variant":
        return "attribute" if row.field.startswith("attribute:") else "product_variant"
    return None


def _sides(db: Session, level: str, ids: set[int]) -> dict[int, dict[str, Any]]:
    """The two sides of a pair, as a person reads them: the name, the brand it sits under and the
    address its page answers at. One query per level, never one per row."""
    if not ids:
        return {}
    if level == "brand":
        return {b.id: {"label": b.name, "detail": b.slug, "path": f"/brands/{b.slug}", "brand_slug": b.slug}
                for b in db.scalars(select(Brand).where(Brand.id.in_(ids)))}
    if level == "product_line":
        lines = list(db.scalars(select(ProductLine).where(ProductLine.id.in_(ids))))
        brands = {b.id: b for b in db.scalars(select(Brand).where(Brand.id.in_({l.brand_id for l in lines})))}
        out = {}
        for line in lines:
            brand = brands.get(line.brand_id)
            out[line.id] = {"label": line.name, "detail": f"{brand.name} · {line.key}" if brand else line.key,
                            "path": f"/products/{line.slug}", "brand_slug": brand.slug if brand else None}
        return out
    variants = list(db.scalars(select(ProductVariant).where(ProductVariant.id.in_(ids))))
    brands = {b.id: b for b in db.scalars(select(Brand).where(Brand.id.in_({v.brand_id for v in variants if v.brand_id})))}
    out = {}
    for v in variants:
        brand = brands.get(v.brand_id) if v.brand_id else None
        bits = [x for x in (v.brand, v.gtin) if x]
        out[v.id] = {"label": v.name, "detail": " · ".join(bits) or v.match_key or "",
                     "path": None, "brand_slug": brand.slug if brand else None}
    return out


def rows(db: Session, level: str, *, limit: int = 100, offset: int = 0) -> dict[str, Any]:
    """One page of what is waiting at a level: the pairs first (the older question), then the
    values. Each row says what it would join or set, what was read to suggest it, and what
    approving it changes. Nothing here decides anything; `acted_on` says where it is decided."""
    if level not in LEVELS:
        raise ValueError(f"no such level {level!r}")
    passes = _pass_names(db)
    # A pair proposal is matched to its pair by the row id the LOADER resolved its natural key to
    # (`proposals.entity_id` is `suggestions.id`), never by rebuilding the key here: the key names
    # both sides and either may be written first, and the loader has already done the work.
    reasons: dict[int, Proposal] = {}
    values: list[Proposal] = []
    for row in db.scalars(select(Proposal).where(Proposal.status == "open").order_by(Proposal.id)):
        if _level_of(row) != level:
            continue
        if row.entity_type == "suggestion":
            if row.entity_id:
                reasons[row.entity_id] = row
        else:
            values.append(row)

    out: list[dict[str, Any]] = []
    pair_level = _PAIR_LEVEL.get(level)
    total_pairs = 0
    if pair_level:
        total_pairs = db.scalar(select(func.count(Suggestion.id))
                                .where(Suggestion.level == pair_level, Suggestion.is_open())) or 0
        page = list(db.scalars(select(Suggestion).where(Suggestion.level == pair_level, Suggestion.is_open())
                               .order_by(Suggestion.score.desc().nullslast(), Suggestion.id)
                               .limit(limit).offset(offset)))
        sides = _sides(db, level, {i for s in page for i in (s.left_id, s.right_id) if i})
        for s in page:
            left, right = sides.get(s.left_id or 0), sides.get(s.right_id or 0)
            proposal = reasons.get(s.id)
            brand_slug = (left or {}).get("brand_slug") or (right or {}).get("brand_slug")
            out.append({
                "kind": "pair", "level": level, "uid": str(s.uid), "what": "join",
                "left": left, "right": right, "field": None, "value": None,
                "reason": (proposal.reason if proposal is not None else None) or (s.detail or {}).get("why") or s.reason,
                "evidence": [e for e in ((proposal.evidence if proposal is not None else None) or [])][:4],
                "score": float(s.score) if s.score is not None else None,
                "rule": _rule_of(proposal, passes), "changes": WHAT_IT_CHANGES[level],
                "brand_slug": brand_slug if proposal is not None else None,
                "acted_on": "sheet" if proposal is not None and brand_slug else "desk",
            })

    if offset + limit > total_pairs:
        start = max(0, offset - total_pairs)
        for row in values[start : start + max(0, limit - max(0, total_pairs - offset))]:
            out.append(_value_row(db, row, level, passes))
    return {"level": level, "label": LABEL[level], "asks": ASKS[level],
            "total": total_pairs + len(values), "pairs": total_pairs, "values": len(values),
            "limit": limit, "offset": offset, "rows": out}


def _rule_of(proposal: Proposal | None, passes: dict[int, ProposalPass]) -> str | None:
    if proposal is None:
        return None
    row = passes.get(proposal.pass_id)
    return row.name if row is not None else None


def _value_row(db: Session, row: Proposal, level: str, passes: dict[int, ProposalPass]) -> dict[str, Any]:
    entity = _entity_label(db, row)
    return {
        "kind": "value", "level": level, "uid": str(row.uid), "what": "set",
        "left": entity, "right": None, "field": row.field, "value": row.value,
        "reason": row.reason, "evidence": (row.evidence or [])[:4], "score": None,
        "rule": _rule_of(row, passes),
        "changes": WHAT_IT_CHANGES["attribute"] if row.field.startswith("attribute:") else
                   "The value a person confirms is recorded and survives every later recomputation.",
        "brand_slug": row.brand_slug or None, "acted_on": "sheet" if row.brand_slug else "cli",
    }


def _entity_label(db: Session, row: Proposal) -> dict[str, Any]:
    if row.entity_type == "brand":
        brand = db.scalar(select(Brand).where(Brand.slug == (row.natural_key or "").partition(":")[2]))
        return {"label": brand.name if brand else row.natural_key, "detail": row.natural_key,
                "path": f"/brands/{brand.slug}" if brand else None, "brand_slug": brand.slug if brand else None}
    if row.entity_type == "attribute_wording":
        wording = (row.natural_key or "").partition("|")[2]
        return {"label": wording or row.natural_key, "detail": row.natural_key, "path": None, "brand_slug": None}
    if row.entity_type == "product_variant" and row.entity_id:
        v = db.get(ProductVariant, row.entity_id)
        if v is not None:
            return {"label": v.name, "detail": v.brand or "", "path": None, "brand_slug": row.brand_slug}
    if row.entity_type == "product_line" and row.entity_id:
        line = db.get(ProductLine, row.entity_id)
        if line is not None:
            return {"label": line.name, "detail": line.key, "path": f"/products/{line.slug}", "brand_slug": row.brand_slug}
    return {"label": row.natural_key, "detail": "", "path": None, "brand_slug": row.brand_slug}
