"""API response shapes. The frontend's TypeScript client is generated from these."""

from datetime import datetime
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator


class AwardOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    competition: str
    competition_slug: str | None = None
    year: int | None = None
    medal: str | None = None
    is_own_competition: bool = False


class PriceOut(BaseModel):
    """A price as observed at one shop, with the context it was seen under."""

    shop_code: str
    shop_name: str
    shop_iata: str | None = None
    shop_city: str | None = None
    shop_country: str | None = None
    #: The airport page this row links to (urls.airport_path); None for an
    #: online catalogue. Sent so the SPA never rebuilds an address.
    shop_path: str | None = None
    retailer_name: str
    is_catalogue_only: bool
    currency: str
    price: float
    price_usd: float | None = None
    was_price: float | None = None
    price_type: str
    in_stock: bool | None = None
    observed_at: datetime
    url: str | None = None


class TopAward(BaseModel):
    """A product's most impressive medal, for the card corner's artwork."""

    competition_slug: str | None = None
    competition: str
    medal: str | None = None
    year: int | None = None


class ShopPrice(BaseModel):
    """One shop's latest price, shaped for a card's inline comparison."""

    label: str
    usd: float
    in_stock: bool | None = None


class ProductSuggestion(BaseModel):
    """A product as the header search suggests it while a shopper types: enough to
    recognise the bottle and open its page. No price: a suggestion is for finding
    the product, and the product page carries every price with its date."""

    id: int
    name: str
    brand: str | None = None
    category: str | None = None
    thumb_url: str | None = None
    #: Shops (retail shops) that list it, as ProductSummary.shop_count counts them.
    shop_count: int = 0
    #: Where it opens: its product line with this variant chosen (Stream K5), else None and the
    #: SPA falls back to the variant address, which 301s.
    path: str | None = None


class ProductSummary(BaseModel):
    id: int
    name: str
    brand: str | None = None
    #: The brand page's slug (`/brands/<slug>`), only when the brand has a page:
    #: a brand too thin for one gets None, and the brand name links to a
    #: catalogue search instead of a 404 (urls.brand_path / lib/urls.ts brandPath).
    brand_slug: str | None = None
    gtin: str | None = None
    category: str | None = None
    quantity_ml: int | None = None
    abv: float | None = None
    is_exclusive: bool = False
    thumb_url: str | None = None
    image_source: str | None = None
    shop_count: int
    # When the shopper has chosen airports, these describe the deal available to
    # them specifically rather than the best price anywhere in the world.
    best_shop: str | None = None
    best_shop_iata: str | None = None
    #: Where it is sold, cheapest first, short labels, capped for display.
    shop_labels: list[str] = Field(default_factory=list)
    #: The same shops with their prices, for the card's inline comparison.
    top_prices: list[ShopPrice] = Field(default_factory=list)
    top_award: TopAward | None = None
    cheapest_usd: float | None = None
    dearest_usd: float | None = None
    award_count: int = 0
    #: The card's deep link (plan W1): its product line with this variant and the airports the
    #: card's own comparison shows, `/products/<line>?variant=<id>&airports=LHR,CDG`.
    path: str | None = None


class ProductDetail(ProductSummary):
    country_of_origin: str | None = None
    image_url: str | None = None
    prices: list[PriceOut] = []
    awards: list[AwardOut] = []


class ProductPage(BaseModel):
    total: int
    limit: int
    offset: int
    items: list[ProductSummary]


class CategoryCount(BaseModel):
    category: str
    count: int
    #: The category's family (taxonomy.vertical_of: liquor, beauty, ...) and the
    #: shopper's word for it (taxonomy.family_label: Drinks, Beauty, ...), so a
    #: menu can group shelves without typing the taxonomy into the SPA. Optional:
    #: a count built without them is unchanged.
    family: str | None = None
    family_label: str | None = None


class StatsOut(BaseModel):
    product_variants: int
    product_variants_multi_shop: int
    shops: int
    retailers: int
    observations: int
    awards: int
    last_collected_at: datetime | None = None
    categories: list[CategoryCount] = []


class SourceOut(BaseModel):
    slug: str
    name: str
    enabled: bool
    last_status: str | None = None
    last_run_at: datetime | None = None
    last_prices: int | None = None
    last_error: str | None = None


class HealthOut(BaseModel):
    status: str
    version: str
    database: str


class DiscussionCommentOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    author: str
    body: str
    created_at: datetime


class DiscussionCommentIn(BaseModel):
    """The author is the session's effective account, never a payload field."""

    body: str


class FeaturePriorityOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    feature_key: str
    priority: str
    author: str
    updated_at: datetime


class FeaturePriorityIn(BaseModel):
    # Strip BEFORE the length checks, so " " cannot sneak past min_length.
    feature_key: str = Field(min_length=1, max_length=64)
    # Timing values (not-yet-built rows) and effort values (built rows).
    priority: Literal["essential", "next", "later", "no", "good", "more", "heavy", "drop"]

    @field_validator("feature_key", mode="before")
    @classmethod
    def _strip(cls, value: object) -> object:
        return value.strip() if isinstance(value, str) else value


class QuoteSelectionOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    item_key: str
    included: bool
    author: str
    updated_at: datetime


class QuoteSelectionIn(BaseModel):
    item_key: str = Field(min_length=1, max_length=64)
    included: bool

    @field_validator("item_key", mode="before")
    @classmethod
    def _strip_quote(cls, value: object) -> object:
        return value.strip() if isinstance(value, str) else value


class QuoteRequestOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    author: str
    note: str | None
    items: str
    total_usd: int
    created_at: datetime


class QuoteRequestIn(BaseModel):
    note: str | None = Field(default=None, max_length=2000)
    items: list[str] = Field(min_length=1)
    total_usd: int = Field(ge=0, le=1_000_000)


class FeatureCommentOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    feature_key: str
    author: str
    body: str
    created_at: datetime
    # Moves when the body is edited, so a page can mark a comment as edited
    # rather than let it change silently. Set on insert too, so compare with a
    # tolerance rather than for equality.
    updated_at: datetime
    # Stamped by an edit (migration #6); the reliable "edited" signal.
    edited_at: datetime | None = None
    thread_id: int | None = None


class FeatureCommentIn(BaseModel):
    feature_key: str = Field(min_length=1, max_length=80)
    body: str = Field(min_length=1, max_length=2000)

    @field_validator("feature_key", "body", mode="before")
    @classmethod
    def _strip(cls, value: object) -> object:
        return value.strip() if isinstance(value, str) else value


class FeatureCommentEdit(BaseModel):
    """The body of an existing comment. The author is never editable: a comment
    carries someone's words, and re-attributing them is not a correction."""

    body: str = Field(min_length=1, max_length=2000)

    @field_validator("body", mode="before")
    @classmethod
    def _strip(cls, value: object) -> object:
        return value.strip() if isinstance(value, str) else value


class DiscussionItemOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    title: str
    note: str | None = None
    reality: str | None = None
    recommendation: str | None = None
    theme: str | None = None
    needs_decision: bool = False
    resolved: bool = False
    sort_order: int = 100
    comments: list[DiscussionCommentOut] = Field(default_factory=list)


class DiscussionItemIn(BaseModel):
    title: str
    note: str | None = None
    theme: str | None = None
    needs_decision: bool | None = None
    sort_order: int | None = None


class TripPrice(BaseModel):
    in_stock: bool | None = None
    shop_name: str
    shop_iata: str | None = None
    retailer_name: str
    price_usd: float
    currency: str
    price: float


class TripProduct(BaseModel):
    id: int
    name: str
    brand: str | None = None
    category: str | None = None
    quantity_ml: int | None = None
    thumb_url: str | None = None
    is_exclusive: bool = False
    award_count: int = 0
    best: TripPrice
    worst: TripPrice
    saving_usd: float


class TripStop(BaseModel):
    code: str
    iata: str | None = None
    name: str
    city: str | None = None
    country: str | None = None


class TripResult(BaseModel):
    stops: list[TripStop]
    compared: int
    items: list[TripProduct]


class CoverageShop(BaseModel):
    code: str
    name: str
    iata: str | None = None
    #: The airport page (urls.airport_path); None for a catalogue or a shop
    #: with no IATA code, which have none.
    path: str | None = None
    city: str | None = None
    country: str | None = None
    currency: str
    retailer_name: str
    is_catalogue_only: bool = False
    product_variants: int = 0
    with_barcode: int = 0
    last_collected_at: datetime | None = None
    #: Whether a person has written this airport's duty free guide
    #: (`services.airport_guides`). Nothing on the storefront reads it; the
    #: demo's placeholder view marks which airports are still to write.
    has_guide: bool = False


class UncoveredAirport(BaseModel):
    name: str
    iata: str
    operator: str
    reason: str


class SuggestedRoute(BaseModel):
    codes: list[str]
    labels: list[str]
    compared: int


class ThreadCommentOut(BaseModel):
    id: int
    author: str
    author_id: int | None
    body: str
    at: str | None
    edited_at: str | None
    #: The one identity fact the page needs: the UI consumes booleans, never ids.
    mine: bool
    thread_id: int | None = None
    #: The thread a re-filed comment came from (T18); None for a comment that never moved.
    moved_from_thread_id: int | None = None
    #: "Got it" (the workflow plan): the names, oldest first, and whether the viewer's is among them.
    acks: list[str] = []
    acked: bool = False
    #: The viewer's own flag on the comment (rian's card design); nobody else's is shown.
    flagged: bool = False


class AskOut(BaseModel):
    """One hand-off on a thread: for whom, what, by whom, and done by whom when it is."""

    id: int
    for_: str = Field(alias="for")
    for_id: int
    note: str
    by: str
    by_id: int | None
    at: str | None
    done_at: str | None
    done_by: str | None
    #: `later`, `resolve`, `reply` (the person asked gets the button), or None: words, done by hand.
    kind: str | None = None
    #: The two identity facts the page needs: is this ask for the viewer; did the viewer ask it.
    mine: bool
    by_me: bool = False

    model_config = {"populate_by_name": True}


class AskIn(BaseModel):
    """Ask one person for one thing: their handle (from the people route), a line saying what
    (optional when a kind is given), and the kind: `later`, `resolve`, `reply`, or none."""

    for_: str = Field(alias="for", min_length=1, max_length=64)
    note: str = Field(default="", max_length=400)
    kind: str | None = Field(default=None, pattern="^(later|resolve|reply)$")

    model_config = {"populate_by_name": True}

    @field_validator("for_", "note", mode="before")
    @classmethod
    def _strip(cls, value: object) -> object:
        return value.strip() if isinstance(value, str) else value


class AskEditIn(BaseModel):
    """Rewrite an open ask: the words and/or the kind (`later`, `resolve`, `reply`, or `other`)."""

    note: str | None = Field(default=None, max_length=400)
    kind: str | None = Field(default=None, pattern="^(later|resolve|reply|other)$")


class AskDoneIn(BaseModel):
    """The ask is done; the word, if any, is posted as the doer's comment."""

    comment: str | None = Field(default=None, max_length=2000)


class FollowupOut(BaseModel):
    """The needs-follow-up mark on a thread (T13): a curator's one-line note, who and when."""

    note: str
    by: str
    by_id: int | None
    at: str


class ThreadOut(BaseModel):
    """One conversation on a subject, with its live comments oldest first, and what it is to
    the viewer: `unread` (a comment by someone else after the viewer's last read of it),
    `needs_you` (an unread mention of the viewer or a turn, the same rows the bell counts),
    the read stamp, and the two dates the panel sorts by."""

    id: int
    subject_type: str
    subject_id: str
    label: str | None
    anchor: str
    resolved: bool
    resolved_by_id: int | None
    resolved_at: str | None
    resolved_by: str | None = None
    outcome: str | None = None
    #: Parked (an open thread marked Later); a decision was made (a resolved thread marked so);
    #: the viewer flagged a comment in it.
    later: bool = False
    decision: bool = False
    flagged: bool = False
    closing_word: str | None = None
    archived: bool = False
    started_at: str | None = None
    last_activity_at: str | None = None
    reply_count: int = 0
    read_at: str | None = None
    unread: bool = False
    needs_you: bool = False
    followup: FollowupOut | None = None
    asks: list[AskOut] = []
    comments: list[ThreadCommentOut]


class CommentIn(BaseModel):
    """A comment on a subject. `label` is the page's breadcrumb for the subject (display text,
    stored on the thread the first time); who is notified is decided server-side."""

    subject_type: str = Field(min_length=1, max_length=24)
    subject_id: str = Field(min_length=1, max_length=80)
    body: str = Field(min_length=1, max_length=2000)
    label: str | None = Field(default=None, max_length=160)

    @field_validator("subject_type", "subject_id", "body", "label", mode="before")
    @classmethod
    def _strip(cls, value: object) -> object:
        return value.strip() if isinstance(value, str) else value


class ResolveIn(BaseModel):
    """Resolve or reopen, with an optional closing (or reopening) word (T17): posted as the
    thread's last comment by the resolver, then the state changes, in one write."""

    resolved: bool = True
    comment: str | None = Field(default=None, max_length=2000)
    #: `done` (plain), `decision` (a decision was made), or the older `later`; ignored when reopening.
    outcome: str = Field(default="done", pattern="^(done|decision|later)$")


class FollowupIn(BaseModel):
    """The needs-follow-up mark (T13): a curator's one-line note, required when marking."""

    note: str = Field(min_length=1, max_length=400)

    @field_validator("note", mode="before")
    @classmethod
    def _strip(cls, value: object) -> object:
        return value.strip() if isinstance(value, str) else value


class FollowupClearIn(BaseModel):
    """Clearing the mark: the note, when given, is posted as the curator's comment."""

    note: str | None = Field(default=None, max_length=2000)


class MoveIn(BaseModel):
    """Re-file a comment under another subject (T18): the target, and the page's breadcrumb
    for it when the target thread does not exist yet."""

    subject_type: str = Field(min_length=1, max_length=24)
    subject_id: str = Field(min_length=1, max_length=80)
    label: str | None = Field(default=None, max_length=160)

    @field_validator("subject_type", "subject_id", "label", mode="before")
    @classmethod
    def _strip(cls, value: object) -> object:
        return value.strip() if isinstance(value, str) else value


class ArchiveIn(BaseModel):
    archived: bool = True


class StageIn(BaseModel):
    stage: str = Field(pattern="^(current|later)$")


class TopicIn(BaseModel):
    """A topic (T15): a title, which becomes the thread's label and slug, and its first comment."""

    title: str = Field(min_length=1, max_length=120)
    body: str = Field(min_length=1, max_length=2000)

    @field_validator("title", "body", mode="before")
    @classmethod
    def _strip(cls, value: object) -> object:
        return value.strip() if isinstance(value, str) else value


# --------------------------------------------------------------------------- the review (Stream K4)
# The sheet, the approval and the undo (`services/proposals.py`; the escalation-2 spec §6, §7).
# A value is whatever JSON the ledger holds for the field (text, a number, a natural key, a pair's
# {decision, survivor, name, note}), so it is typed as JSON, never narrowed here.

JsonValue = dict | list | str | int | float | bool | None


class ReviewSheetSummaryOut(BaseModel):
    brand_slug: str
    brand_name: str
    open: int
    spot_check: int
    held: int
    parked: int
    stale: int
    comparison_gain: int
    listings: int
    newest_pass: str | None = None
    passes: list[str] = []


class ReviewHintSideOut(BaseModel):
    key: str
    kind: str
    name: str | None = None


class ReviewHintOut(BaseModel):
    """One word list's reading of one thing: the pass's input, never a person's question."""
    uid: str
    entity_type: str
    natural_key: str | None = None
    field: str | None = None
    would_suggest: Any = None
    current_value: Any = None
    reason: str | None = None
    confidence: float | None = None
    status: str
    matched: list[str] = []
    read_from: list[str] = []
    sides: list[ReviewHintSideOut] = []


class ReviewHintListOut(BaseModel):
    list: str
    pass_name: str
    kind: str
    withdrawn: bool
    withdrawn_reason: str | None = None
    rules_version: str | None = None
    count: int
    suggestions: list[ReviewHintOut] = []


class ReviewHintsOut(BaseModel):
    brand: dict[str, Any]
    lists: list[ReviewHintListOut] = []
    total: int


class ReviewLinePricesOut(BaseModel):
    count: int = 0
    low_usd: float | None = None
    high_usd: float | None = None
    shops: int = 0


class ReviewLineCountsOut(BaseModel):
    variants: int = 0
    listings: int = 0
    folds: int = 0
    open: int = 0
    spot_check: int = 0


class ReviewLineTableOut(BaseModel):
    """One proposed product line and every listing that would sit in it (K11.5, walk-through W10):
    the decided display name, the attribute kinds that vary, the shops with their prices and a link
    to the source, and each proposed fold with its reason."""

    brand: dict[str, Any]
    pass_name: str | None = None
    ref: str
    line: dict[str, Any]
    columns: list[str] = []
    variants: list[dict[str, Any]] = []
    folds: list[dict[str, Any]] = []
    prices: ReviewLinePricesOut = ReviewLinePricesOut()
    counts: ReviewLineCountsOut = ReviewLineCountsOut()


class ReviewNoteSideOut(BaseModel):
    key: str
    kind: str
    name: str | None = None


class ReviewNoteOut(BaseModel):
    """One note rian left when he deferred or rejected a question."""
    uid: str
    status: str
    note: str | None = None
    brand_slug: str | None = None
    entity_type: str
    natural_key: str | None = None
    field: str | None = None
    proposed: Any = None
    sheet_line_ref: str | None = None
    by: str | None = None
    at: datetime | None = None
    pass_name: str | None = None
    sides: list[ReviewNoteSideOut] = []


class ReviewNotesOut(BaseModel):
    brand: dict[str, Any] | None = None
    notes: list[ReviewNoteOut] = []
    deferred: int
    total: int


class ReviewEvidenceOut(BaseModel):
    listing: str | None = None
    source: str | None = None
    span: list[int] | None = None
    text: str | None = None
    #: The words a RULE matched inside the text it read. A rule has no character span (it matches
    #: the folded form of a name, and cites no single listing), so it names what it matched here.
    matched: str | None = None
    #: The listed text read now; the span is highlighted in it.
    listed_text: str | None = None
    ok: bool
    why: str | None = None


class ReviewListingOut(BaseModel):
    key: str
    shop: str | None = None
    shop_name: str | None = None
    listed_name: str | None = None
    listed_variant: str | None = None
    listed_quantity_text: str | None = None
    gone: bool
    ignored: bool


class ReviewEffectiveOut(BaseModel):
    id: int
    uid: str
    value: JsonValue = None
    by: str | None = None
    at: datetime
    mode: str
    origin: str
    pass_name: str | None = None
    process_version: str | None = None
    batch_uid: str | None = None
    reason: str | None = None


class CollectionRuleOut(BaseModel):
    key: str
    name: str
    what: str
    applies_to: str
    example: str | None = None


class SuggestingListOut(BaseModel):
    key: str
    name: str
    what: str
    word_count: int
    verticals: list[str] = []


class CollectionRulesOut(BaseModel):
    """What is cleaning the data right now, and what only suggests."""

    acting: list[CollectionRuleOut] = []
    suggesting: list[SuggestingListOut] = []


class GuidelineOut(BaseModel):
    headline: str
    anchor: str
    html: str


class AttentionLevelOut(BaseModel):
    level: str
    what: str


class GuidelinesOut(BaseModel):
    missing: bool = False
    path: str | None = None
    guidelines: list[GuidelineOut] = []
    #: The five levels, so the page names them once and the pass rates against the same words.
    attention: list[AttentionLevelOut] = []


class BrandSpellingOut(BaseModel):
    """One spelling a shop actually sent, with how much of the catalogue wrote it that way.
    An empty spelling is a shop that sent no brand field; it is shown, not dropped."""

    spelling: str
    listings: int
    shops: int


class BrandCardOut(BaseModel):
    """The brand at the top of its own review page: what it shows as, what the shops call it,
    where its page is and whether that page is unlisted, noindex or indexable."""

    slug: str
    name: str
    path: str
    status: Literal["unlisted", "noindex", "indexable"]
    variants: int
    spellings: list[BrandSpellingOut] = []
    named_spellings: int = 0
    changed: list[str] = []
    batch_uid: str | None = None


class BrandCardIn(BaseModel):
    name: str | None = Field(default=None, max_length=160)
    status: Literal["unlisted", "noindex", "indexable"] | None = None


class BrandIndexRowOut(BaseModel):
    brand_slug: str
    brand_name: str
    pending: int
    listings: int
    product_lines: int = 0
    product_variants: int = 0
    #: Open brand folds naming this brand: the question that is answered before any other on it.
    brand_decisions: int = 0
    spot_check: int = 0
    held: int = 0
    parked: int = 0
    stale: int = 0
    comparison_gain: int = 0
    newest_pass: str | None = None


class BrandIndexOut(BaseModel):
    """Every brand, with what is waiting on it. Not only the ones with something waiting."""

    total: int
    with_pending: int
    limit: int
    offset: int
    rows: list[BrandIndexRowOut] = []


class ReviewSideOut(BaseModel):
    """One side of a pair (or of a merge) by the name a person reads, never its key."""

    key: str
    kind: str
    name: str | None = None
class ReviewWordListOut(BaseModel):
    """One word list as the review area shows it: its words, where it applies, whether it ACTS
    or only proposes, what it did, and where to go and look."""

    key: str
    what: str
    words: list[str]
    word_count: int
    verticals: list[str]
    #: True for the brand trailer list, the one list that still removes a word rather than
    #: proposing that it be removed. Everything else writes proposals and applies nothing.
    acts: bool
    pattern: str | None = None
    pass_name: str | None = None
    withdrawn: bool
    proposals: dict[str, int]
    brands: list[str]
    brand_count: int
    #: Brand rows that hold more than one listed spelling because this list folded them; null
    #: for a list that only proposes, where the proposal counts say the same thing.
    brand_rows_folded: int | None = None


class ReviewSplitSpellingOut(BaseModel):
    spelling: str
    variants: int
    #: Whether the trailer list is what put this spelling on the row (ticked by default).
    folded: bool
    words: list[str]


class ReviewSplitOfferOut(BaseModel):
    """What a rejected fold proposal offers instead of dying: the spellings on the row, the
    folded ones marked, for a person to move onto a brand of their own. An offer, never an act."""

    brand_slug: str
    brand_name: str
    spellings: list[ReviewSplitSpellingOut]


class ReviewRowOut(BaseModel):
    uid: str
    entity_type: str
    natural_key: str
    entity_id: int | None = None
    resolution: str
    field: str
    proposed: JsonValue = None
    corrected: JsonValue = None
    rule_value: JsonValue = None
    current_value: JsonValue = None
    loaded_value: JsonValue = None
    drift: bool
    confidence: float | None = None
    reason: str | None = None
    evidence: list[ReviewEvidenceOut]
    spot_check: bool
    status: str
    stale_now: bool
    reopened: bool
    stale_why: str | None = None
    resolution_note: str | None = None
    listings: list[ReviewListingOut]
    effective: ReviewEffectiveOut | None = None
    disagrees_with_bulk: bool
    held: bool
    previously_rejected: str | None = None
    generator: str
    collapsed: bool
    decision_id: int | None = None
    position: int
    sheet_line_ref: str | None = None
    is_header: bool
    subject_name: str | None = None
    sides: list[ReviewSideOut] = []
    #: The pass this row came from. Several are shown at once, and an approval is scoped to one.
    pass_name: str | None = None
    #: How much of a person's judgement this needs: critical, high, medium, low or none. Not the
    #: same axis as `confidence`, which is how sure the pass was about the words it read.
    attention: Literal["critical", "high", "medium", "low", "none"] | None = None
    #: The kind of suggestion, shown as the card's eyebrow so a person learns the shapes.
    category: str = "other"
    category_label: str = "Waiting for an answer"
    #: True for the proposal that two brand rows are one: the outer question on a sheet.
    is_brand_fold: bool = False
    #: True while a brand fold on this sheet is unanswered: the row is drawn and cannot be decided.
    blocked_by_brand_fold: bool = False
    #: What a rejected fold proposal offers instead of dying (K9): the row's spellings, the
    #: folded ones marked, for a person to move onto a brand of their own.
    split_offer: ReviewSplitOfferOut | None = None


class ReviewSplitIn(BaseModel):
    spellings: list[str] = Field(min_length=1, max_length=200)
    name: str = Field(min_length=1, max_length=160)
    force: bool = False


class ReviewSplitOut(BaseModel):
    from_brand: str
    brand_slug: str | None = None
    brand_uid: str | None = None
    minted: bool
    product_variants_moved: int
    lines_created: int
    lines_pruned: int
    line_decisions_carried: int
    groups_merged: int
    rows_merged: int
    decision_uid: str
    batch_uid: str
    undo: str
    message: str


class FoldingCountOut(BaseModel):
    """What is waiting at one level. Pairs and values are counted separately and never added
    twice: a rule's reading of a pair is a reason ON that pair, not a second row beside it."""

    level: str
    label: str
    asks: str
    changes: str
    pairs: int
    pairs_with_a_reason: int
    values: int
    total: int


class FoldingSideOut(BaseModel):
    label: str
    detail: str
    path: str | None = None
    brand_slug: str | None = None


class FoldingRowOut(BaseModel):
    kind: Literal["pair", "value"]
    level: str
    uid: str
    what: Literal["join", "set"]
    left: FoldingSideOut | None = None
    right: FoldingSideOut | None = None
    field: str | None = None
    value: JsonValue = None
    reason: str | None = None
    evidence: list[dict] = Field(default_factory=list)
    score: float | None = None
    rule: str | None = None
    changes: str
    brand_slug: str | None = None
    #: Where it is decided: a brand's sheet, the merge desk, or the command line.
    acted_on: Literal["sheet", "desk", "cli"]


class FoldingPageOut(BaseModel):
    level: str
    label: str
    asks: str
    total: int
    pairs: int
    values: int
    limit: int
    offset: int
    rows: list[FoldingRowOut]


class ReviewProcessSectionOut(BaseModel):
    """One `##` section of `docs/REVIEW-PROCESS.md`, rendered. The doc is the authority and is
    READ, never restated: a second copy of a rule in the page goes stale the first time the doc
    changes, and the doc's version line is a rules version."""

    number: str | None = None
    title: str
    anchor: str
    html: str


class ReviewProcessPassOut(BaseModel):
    name: str
    kind: str
    generator: str
    process_version: str | None = None
    rules_version: str | None = None
    withdrawn: bool
    open: int
    total: int
    brands: list[str]
    brand_count: int


class ReviewProcessOut(BaseModel):
    version: str | None = None
    version_line: str | None = None
    dated: str | None = None
    path: str | None = None
    #: True when the document is not on disk: the page says so rather than showing a blank tab.
    missing: bool
    sections: list[ReviewProcessSectionOut]
    passes: list[ReviewProcessPassOut]


class ReviewAbsorbOut(BaseModel):
    key: str
    name: str | None = None
    slug: str | None = None
    members: int
    aliased: bool
    missing: bool


class ReviewGroupOut(BaseModel):
    ref: str | None = None
    #: The pass whose rows these are, so the group's own approval names it.
    pass_name: str | None = None
    name: JsonValue = None
    header_uid: str | None = None
    absorbs: list[ReviewAbsorbOut]
    rows: list[ReviewRowOut]


class ReviewBatchOut(BaseModel):
    uid: str
    opened_at: datetime
    by: str | None = None
    mode: str
    kind: str
    pass_name: str | None = None
    note: str | None = None
    scope: dict | None = None
    decisions: int
    held: int
    refused: int
    undone: bool
    undone_by_uid: str | None = None
    undone_at: datetime | None = None


class ReviewPassOut(BaseModel):
    name: str
    kind: str
    process_version: str
    rules_version: str
    loaded_at: datetime | None = None
    loaded_by: str | None = None
    withdrawn: bool
    counts: dict


class ReviewBrandOut(BaseModel):
    slug: str
    name: str
    aliases: list[str]


class ReviewBrandGateOut(BaseModel):
    """The brand fold a sheet is waiting on, and how much it is holding back."""

    uids: list[str]
    sides: list[ReviewSideOut] = []
    blocked: int


class ReviewSheetOut(BaseModel):
    #: How many waiting rows sit at each attention level, highest first.
    attention_counts: dict[str, int] = {}
    brand_gate: ReviewBrandGateOut | None = None
    brand: ReviewBrandOut
    passes: list[ReviewPassOut]
    pass_name: str | None = None
    counts: dict[str, int]
    spot_check_uids: list[str]
    groups: list[ReviewGroupOut]
    batches: list[ReviewBatchOut]


class ReviewDecisionOut(BaseModel):
    id: int
    uid: str
    decided_at: datetime
    entity_type: str
    entity_id: int
    natural_key: str
    field: str
    effect: str
    value: JsonValue = None
    rule_value: JsonValue = None
    prior_value: JsonValue = None
    origin: str
    mode: str
    by: str | None = None
    reason: str | None = None
    pass_name: str | None = None
    batch_uid: str | None = None
    reverses_id: int | None = None
    restores_id: int | None = None
    caused_by_id: int | None = None
    replayed_from: str | None = None


class RowSubjectOut(BaseModel):
    key: str
    kind: str
    #: What happens to this side if the suggestion is confirmed, in words.
    becomes: str = ""
    name: str | None = None
    slug: str | None = None
    variants: int = 0
    listings: int = 0
    lines: int = 0


class RowLineOut(BaseModel):
    key: str
    name: str
    slug: str | None = None
    brand: str | None = None
    variant_uids: list[str] = []
    listings: int = 0


class RowConsequenceOut(BaseModel):
    what: str
    count: int | None = None


class RowConsequencesOut(BaseModel):
    """What answering unlocks, as a shape. Never the queued questions themselves: showing those
    would bias the answer that has not been made yet."""

    if_confirmed: list[RowConsequenceOut] = []
    if_separate: list[RowConsequenceOut] = []
    blocked_now: int = 0


class RowListingOut(BaseModel):
    key: str
    variant_uid: str | None = None
    #: The product variant this listing was filed under. For retailers that record none of their
    #: own wording, this and the URL are the only trace of where a reading came from.
    variant_name: str | None = None
    source_sku: str | None = None
    #: When the listing was last collected: what explains an empty `listed_*`.
    last_seen: datetime | None = None
    shop: str | None = None
    shop_name: str | None = None
    airport: str | None = None
    retailer: str | None = None
    listed_brand: str | None = None
    listed_name: str | None = None
    listed_variant: str | None = None
    listed_quantity_text: str | None = None
    gtin: str | None = None
    url: str | None = None


class RowListingsOut(BaseModel):
    """The listings under one product line or one product variant, read when its row is opened."""

    listings: list["RowListingOut"] = []
    total: int = 0
    shown: int = 0


class RowVariantOut(BaseModel):
    uid: str
    name: str
    gtin: str | None = None
    quantity_ml: int | None = None
    quantity_state: str | None = None
    attributes: dict[str, JsonValue] = {}
    line_name: str | None = None
    product_line_key: str | None = None
    #: The variant's TRUE listing count, not how many the card happened to fetch.
    listings: int = 0


class RowConflictValueOut(BaseModel):
    value: str
    on: list[str] = []


class RowConflictOut(BaseModel):
    field: str
    label: str
    values: list[RowConflictValueOut] = []
    proposed: str | None = None
    what: str


class ReviewRowDetailOut(BaseModel):
    category: str = "other"
    category_label: str = "Waiting for an answer"
    """One suggestion opened up: what it is about, the listings behind it, what would be merged,
    what conflicts, and what has already been decided."""

    uid: str
    question: str
    field: str
    entity_type: str
    natural_key: str
    status: str
    confidence: float | None = None
    reason: str | None = None
    pass_name: str | None = None
    is_brand_fold: bool = False
    subjects: list[RowSubjectOut] = []
    variants: list[RowVariantOut] = []
    lines: list[RowLineOut] = []
    consequences: RowConsequencesOut | None = None
    listings: list[RowListingOut] = []
    listings_total: int = 0
    listings_shown: int = 0
    #: True only where variants become one, which is the only case a conflict can arise in.
    merges_variants: bool = False
    conflicts: list[RowConflictOut] = []
    ledger: list[ReviewDecisionOut] = []


class ReviewBatchDetailOut(BaseModel):
    uid: str
    kind: str
    mode: str
    by: str | None = None
    opened_at: datetime
    closed_at: datetime | None = None
    pass_name: str | None = None
    scope: dict | None = None
    note: str | None = None
    summary: dict
    undone: bool
    undone_by_uid: str | None = None
    reverses_uid: str | None = None
    decisions: list[ReviewDecisionOut]


class ReviewScopeIn(BaseModel):
    sheet_line_ref: str | None = Field(default=None, max_length=500)
    proposal_uids: list[str] | None = Field(default=None, max_length=5000)


class ReviewApproveIn(BaseModel):
    """`scope` is "all" (every waiting row except spot-checks), one product line, or explicit
    uids; `corrections` approve a proposal with a value a person set (the API and the CLI take
    them; the page offers none yet); `reject` and `defer` each need `note`, one sentence.

    Three answers (K11.4): approve, reject ("no"), and defer ("not yet, and here is why"). A
    deferred row leaves the queue, is never applied, and its note is what the next pass reads."""

    model_config = ConfigDict(populate_by_name=True)

    pass_name: str = Field(alias="pass", max_length=120)
    scope: Literal["all"] | ReviewScopeIn | None = None
    corrections: dict[str, JsonValue] = Field(default_factory=dict)
    reject: list[str] = Field(default_factory=list, max_length=5000)
    defer: list[str] = Field(default_factory=list, max_length=5000)
    #: A defer that asks for a DIFFERENT suggestion rather than a later one. The note is required
    #: and is the something else; the next pass reads it and comes back with a changed proposal.
    counter: list[str] = Field(default_factory=list, max_length=5000)
    note: str | None = Field(default=None, max_length=2000)
    force: bool = False


class ReviewRefusalOut(BaseModel):
    uid: str
    natural_key: str
    field: str
    code: str
    summary: str


class ReviewApproveOut(BaseModel):
    #: Set when this approval confirmed a brand fold: where the suggestions went, and where to
    #: send the person, which is the brand they chose.
    brand_folded_into: str | None = None
    proposals_moved: int = 0
    duplicates_merged: int = 0
    redirect_to: str | None = None
    brand: str
    pass_name: str
    mode: str
    batch_uid: str | None = None
    undo: str | None = None
    counts: dict[str, int]
    refusals: list[ReviewRefusalOut]
    held: list[ReviewRefusalOut]
    absorbs: list[dict]
    #: "N approved, M held, R refused": the page shows it as a banner a person cannot miss.
    message: str


class ReviewUndoIn(BaseModel):
    reason: str = Field(min_length=1, max_length=2000)
    force: bool = False


class ReviewUndoOut(BaseModel):
    undo_batch_uid: str
    reversed_batch_uid: str | None = None
    decision_uid: str | None = None
    decisions: int | None = None
    skipped: list[dict]


# --------------------------------------------------------------------------- the product line page (Stream K5)
# `/products/<line-slug>[?variant=<id>&airports=LHR,CDG]` (plan W1, W20): every product variant of the line,
# the attributes a shopper picks by, and the price block, which prints a price only when it is unambiguous.

#: The W20 cases, in the order the page decides them.
#: `every_place` was `ask_airport` until K10.5: with a variant chosen and no airports the page
#: PRINTS every shop's price rather than naming the airports and linking away to them.
PriceCase = Literal["chosen_airports", "none_at_chosen", "single_place", "every_place", "ask_variant", "no_price"]


class LineAttributeOut(BaseModel):
    """One attribute of a variant, read through the registry's one accessor (`attributes.of`)."""

    kind: str
    label: str
    #: The value as a shopper reads it ("100 ml", "40%", "Elixir", "812 beige brut").
    text: str
    #: picked (a selector), shown (a fact about what shops carry), fact (the details block).
    display: str


class LineVariantOut(BaseModel):
    id: int
    name: str
    gtin: str | None = None
    quantity_ml: int | None = None
    attributes: list[LineAttributeOut] = []
    thumb_url: str | None = None
    image_url: str | None = None
    image_source: str | None = None
    is_exclusive: bool = False
    country_of_origin: str | None = None
    #: Comparison units (shops counted once per primary place) that price it, catalogues excluded.
    units: int = 0
    #: Two or more units: a comparison exists.
    comparable: bool = False
    #: The airport codes that stock it, sorted.
    airports: list[str] = []
    prices: list[PriceOut] = []
    award_count: int = 0
    top_award: TopAward | None = None
    awards: list[AwardOut] = []


class LineOptionOut(BaseModel):
    text: str
    variant_ids: list[int]


class LineSelectorOut(BaseModel):
    kind: str
    label: str
    options: list[LineOptionOut]


class LineShownOut(BaseModel):
    kind: str
    label: str
    values: list[str]


class LineAirportOut(BaseModel):
    iata: str
    path: str | None = None


class LinePriceBlockOut(BaseModel):
    """What the page may print as a price, decided once on the server so the served body and the
    SPA agree. `prices` is empty unless the case prints one; catalogue shops never price a page."""

    case: PriceCase
    variant_id: int | None = None
    airports: list[str] = []
    prices: list[PriceOut] = []
    #: The chosen airports that do not stock the chosen variant.
    missing_airports: list[str] = []
    #: Where the chosen variant is stocked: offered when the shopper's airports have none, or
    #: when no airport is chosen and it is at several.
    elsewhere: list[LineAirportOut] = []
    #: With airports chosen and no variant: the variants stocked at one of them, marked as such.
    variants_at_airports: list[int] = []
    #: With no variant chosen: every variant, so the block itself offers the choice. It used to
    #: offer only `variants_at_airports`, which is empty when no airport is chosen, so the block
    #: asked for a choice it gave no way to make (rian, 17 Sep).
    variants_offered: list[int] = []
    #: The chosen variant's price at every shop OUTSIDE the chosen airports, so a shopper can see
    #: what it costs elsewhere without giving up the airports they chose.
    elsewhere_prices: list[PriceOut] = []


class ProductLineDetail(BaseModel):
    id: int
    slug: str
    name: str
    #: The bare line address, the canonical of every `?variant` and `&airports` view.
    path: str
    brand: str | None = None
    brand_slug: str | None = None
    category: str | None = None
    indexed: bool = False
    variants: list[LineVariantOut] = []
    selectors: list[LineSelectorOut] = []
    shown: list[LineShownOut] = []
    chosen_variant_id: int | None = None
    #: The variant a card stands for: most comparison units, then lowest id.
    representative_variant_id: int | None = None
    airports: list[str] = []
    price_block: LinePriceBlockOut
    last_observed_at: datetime | None = None
