"""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 location, with the context it was seen under."""

    location_code: str
    location_name: str
    location_iata: str | None = None
    location_city: str | None = None
    location_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.
    location_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 locations) that list it, as ProductSummary.location_count counts them.
    location_count: int = 0


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
    size_ml: int | None = None
    abv: float | None = None
    is_exclusive: bool = False
    thumb_url: str | None = None
    image_source: str | None = None
    location_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_location: str | None = None
    best_location_iata: str | None = None
    #: Where it is sold, cheapest first, short labels, capped for display.
    location_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


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):
    products: int
    products_multi_location: int
    locations: 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
    location_name: str
    location_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
    size_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 CoverageLocation(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
    products: 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
