"""API response shapes for the hub pages (airports now; brands and categories follow).

Kept apart from `schemas.py` so the hub work never edits the catalogue's shapes
while another stream owns them. The frontend's TypeScript client is generated
from these like every other response model.
"""

from datetime import datetime

from pydantic import BaseModel

from app.models.editorial import ArticleOut
from app.models.place_guide import PlaceGuide
from app.models.schemas import CategoryCount, ProductSummary


class AirportShop(BaseModel):
    """One retailer's storefront at the airport (an airport can hold several)."""

    code: str
    retailer_name: str
    product_variants: int = 0
    last_collected_at: datetime | None = None


class AirportHoursOut(BaseModel):
    """An airport's current opening hours and where they came from (`services/hours/store.py`):
    collected from the operator's site on a date, or entered by a named account on a date.

    `GET /api/airports/{iata}/hours` answers kind "none" where neither exists; on the airport page
    the field is simply absent instead. Hours are never part of the guide document: a document is
    imported from a file, and a file import must not be able to overwrite what a person entered.
    The public page prints the date beside the hours and nothing more; the owner's views print the
    whole line."""

    kind: str  # collected | hand | none
    text: str | None = None
    observed_at: datetime | None = None
    entered_by_username: str | None = None
    source_url: str | None = None
    #: The host the hours were read from ("www.heathrow.com"), for the owner's line.
    source_host: str | None = None


class FeaturedFamily(BaseModel):
    """One row of "Featured at this airport": a family and the best-value comparables in it
    here (`services/airport_featured.py`). A family with nothing comparable has no row."""

    key: str
    label: str
    items: list[ProductSummary] = []


class CategorySection(BaseModel):
    """One named part of a category's primer. `teaser` is the line the closed row shows,
    so a reader can tell whether opening it is worth their time; `body` is the part
    itself. Both are the writer's own sentences: only the grouping is ours."""

    title: str
    teaser: str
    body: str


class CategoryGuide(BaseModel):
    """The written primer for a product category (`services.category_guides`): what the
    category is, the one thing worth knowing while standing at the shelf, and the longer
    parts a reader opens only if they want them.

    Split this way on purpose. A category page that carried the primer as one block of
    prose would be the recipe-page failure: a reader who came for prices scrolling past a
    history of distillation to reach them. `summary` and `takeaway` are the two parts that
    earn their place unprompted; everything else opens on a click."""

    summary: str
    #: The buying steer, one sentence, shown beside the summary because it is the part a
    #: shopper comparing prices can actually act on.
    takeaway: str | None = None
    sections: list[CategorySection] = []


class CategoryPageLink(BaseModel):
    """One category-at-airport page that exists: the pair is at or over the coverage bar."""

    category: str
    slug: str
    path: str
    count: int = 0


class AirportSummary(BaseModel):
    """An airport with a page: the identity, the words in its URL, and its size."""

    iata: str
    path: str
    name: str
    city: str | None = None
    country: str | None = None
    currency: str
    product_variants: int = 0
    last_collected_at: datetime | None = None


class AirportDetail(AirportSummary):
    """Everything the airport page shows. Every number here is counted in the
    database; the page never types one."""

    # Plain `= []` defaults on purpose: they reach the OpenAPI schema as
    # defaults, so the generated TypeScript types are required, not optional.
    shops: list[AirportShop] = []
    #: Products here that are also priced at another airport we track.
    comparable: int = 0
    #: Comparable products for which this airport is the cheapest place we know.
    cheapest_here: int = 0
    exclusives: int = 0
    categories: list[CategoryCount] = []
    #: The biggest savings for a shopper standing at this airport, best first.
    savings: list[ProductSummary] = []
    #: A sample of the travel-retail exclusives sold here.
    exclusive_items: list[ProductSummary] = []
    #: The best-value comparables here, one row per family that has any; never padded.
    featured: list[FeaturedFamily] = []
    #: The category-at-airport pages this airport has (the pairs over the coverage bar).
    category_pages: list[CategoryPageLink] = []
    # The product list, one page of it, with the filters that shaped it.
    category: str | None = None
    #: The storefront's shopping-feature filters on the full list (`/api/products` names).
    multi_only: bool = False
    awarded_only: bool = False
    exclusives_only: bool = False
    sort: str = "featured"
    total: int = 0
    limit: int = 24
    offset: int = 0
    items: list[ProductSummary] = []
    #: The Professor's published write-up for this airport, when there is one
    #: (`editorial.airport_writeup`); seeded with the page so the SPA draws it
    #: without a second request and the server body shows the same text.
    writeup: ArticleOut | None = None
    #: The written guide to the airport's duty free, where one exists: the document stored on the
    #: place (`services.place_guides`), seeded with the page like the write-up.
    guide: PlaceGuide | None = None
    #: The current opening hours and their provenance, absent where neither a collected nor a hand
    #: row exists. Beside the guide, never inside it.
    hours: AirportHoursOut | None = None
    #: What one part of this place is called, from its kind (`services/places.py`): a terminal here,
    #: a pier at a port, a floor in a mall. The page prints it; it never assumes "terminal".
    area_label: str = "Terminal"


class AirportCategoryDetail(AirportSummary):
    """Everything a category-at-airport page shows (`/airports/<airport>/<category>`): the
    airport's identity, then the category's figures at it, every one counted in the database.
    `product_variants` is the count in the category here; `total` the list's under its filters."""

    shops: list[AirportShop] = []
    category: str
    category_slug: str
    #: The airport's own page, for the crumb and the way back.
    airport_path: str
    #: Products of this category here that are also priced at another airport we track.
    comparable: int = 0
    #: Of those, the ones this airport is the cheapest place we know for.
    cheapest_here: int = 0
    exclusives: int = 0
    exclusive_items: list[ProductSummary] = []
    #: The other category pages this airport has, biggest first.
    siblings: list[CategoryPageLink] = []
    #: The category's own primer, where one is written; None for a category nobody has
    #: written up yet, which renders nothing rather than an empty card.
    guide: CategoryGuide | None = None
    multi_only: bool = False
    awarded_only: bool = False
    exclusives_only: bool = False
    sort: str = "featured"
    total: int = 0
    limit: int = 24
    offset: int = 0
    items: list[ProductSummary] = []


class BrandAirport(BaseModel):
    """One airport that stocks the brand: how much of it, and how often it is
    the cheapest of our airports for a bottle sold at two or more."""

    iata: str
    path: str
    name: str
    product_variants: int = 0
    cheapest_for: int = 0
    last_collected_at: datetime | None = None


class CategoryBrand(BaseModel):
    """One brand worth naming on a category page: how much of the category it accounts for
    here. `path` is the brand's own page, ready-made, so the SPA builds no address."""

    slug: str
    path: str
    name: str
    product_variants: int = 0


class CategorySummary(BaseModel):
    """A category with a page: its name, its address, and the family it sits under. The
    family's own address rides along so a breadcrumb and a sibling rail need no second read."""

    category: str
    slug: str
    path: str
    family: str
    family_label: str
    family_path: str
    product_variants: int = 0
    airports: int = 0


class CategoryDetail(CategorySummary):
    """Everything the site-wide category page shows (`/alcohol/whisky`): the primer, the
    figures, the airports strongest for it, its notable brands, and one page of its products
    under the filters that shaped it. Every number is counted in the database."""

    guide: CategoryGuide | None = None
    #: Products of this category priced at two or more of our airports.
    comparable: int = 0
    exclusives: int = 0
    last_collected_at: datetime | None = None
    #: The airports that stock it best, and how often each is the cheapest place we know.
    airport_rows: list[BrandAirport] = []
    brands: list[CategoryBrand] = []
    #: The family's other categories that have a page, biggest first.
    siblings: list[CategorySummary] = []
    exclusive_items: list[ProductSummary] = []
    savings: list[ProductSummary] = []
    multi_only: bool = False
    awarded_only: bool = False
    exclusives_only: bool = False
    sort: str = "featured"
    total: int = 0
    limit: int = 24
    offset: int = 0
    items: list[ProductSummary] = []


class FamilyDetail(BaseModel):
    """Everything a family landing shows (`/alcohol`): the shopper's word for it, the
    categories beneath it, the biggest savings across all of them, the airports strongest
    for the family, and its travel exclusives. The home page's shape, scoped to one family."""

    family: str
    slug: str
    path: str
    label: str
    product_variants: int = 0
    comparable: int = 0
    cheapest_anywhere: int = 0
    exclusives: int = 0
    last_collected_at: datetime | None = None
    categories: list[CategorySummary] = []
    savings: list[ProductSummary] = []
    airport_rows: list[BrandAirport] = []
    exclusive_items: list[ProductSummary] = []


class BrandSummary(BaseModel):
    """A brand with a page: its slug (the brands table's, never a folded
    string), the spelling shown, and its size."""

    slug: str
    path: str
    name: str
    product_variants: int = 0
    last_collected_at: datetime | None = None
    #: The family most of its products sit in (taxonomy.majority_family: liquor,
    #: beauty, ...), so the header's department panels list their own brands.
    family: str | None = None
    #: The brand's picture (a mark or logo), with its level and credit (services/imagery.py).
    image_url: str | None = None
    thumb_url: str | None = None
    image_source: str | None = None
    image_level: str | None = None
    image_credit: str | None = None


class BrandDetail(BrandSummary):
    """Everything the brand page shows; every number counted in the database."""

    airports: list[BrandAirport] = []
    #: Products of the brand priced at two or more of our airports.
    comparable: int = 0
    exclusives: int = 0
    categories: list[CategoryCount] = []
    # The product list, one page of it, with the filters that shaped it.
    category: str | None = None
    sort: str = "featured"
    total: int = 0
    limit: int = 24
    offset: int = 0
    items: list[ProductSummary] = []


class DatasetFacts(BaseModel):
    """What the public data page and the Dataset markup state, every figure
    counted over visible airports at request time."""

    observations: int = 0
    product_variants: int = 0
    airports: int = 0
    retailers: int = 0
    currencies: int = 0
    with_barcode: int = 0
    awards: int = 0
    first_observed_at: datetime | None = None
    last_observed_at: datetime | None = None
    airport_list: list[AirportSummary] = []
    categories: list[CategoryCount] = []
