"""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


class FeatureCommentIn(BaseModel):
    feature_key: str = Field(min_length=1, max_length=64)
    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
