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

from datetime import datetime
from typing import 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 ReviewEvidenceOut(BaseModel):
    listing: str | None = None
    source: str | None = None
    span: list[int] | None = None
    text: 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 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


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
    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 ReviewSheetOut(BaseModel):
    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 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` needs `note`, one sentence."""

    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)
    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):
    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.
PriceCase = Literal["chosen_airports", "none_at_chosen", "single_place", "ask_airport", "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.
    variants_at_airports: list[int] = []


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
