from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.db import get_db
from app.models import Award, Listing, Shop, PriceObservation, ProductVariant, Retailer
from app.models.hubs import AirportDetail, AirportSummary, BrandDetail, BrandSummary, DatasetFacts
from app.config import settings
from app.models.schemas import ProductDetail, ProductLineDetail, ProductPage, ProductSuggestion, ProductSummary, StatsOut
from app.services import catalog_queries, urls

router = APIRouter(prefix="/api", tags=["catalog"])


@router.get("/products", response_model=ProductPage)
def list_product_variants(
    q: str | None = Query(default=None, max_length=120),
    category: str | None = Query(default=None, max_length=80),
    family: str | None = Query(
        default=None, max_length=40,
        description="A category family (taxonomy's vertical: liquor, beauty); every shown category in it",
    ),
    multi_only: bool = False,
    awarded_only: bool = False,
    exclusives_only: bool = False,
    with_image_only: bool = False,
    at: list[str] = Query(
        default=[], max_length=12,
        description="Airport codes the shopper will pass through",
    ),
    sort: str = Query(default="featured", pattern="^(featured|compared|price|name)$"),
    limit: int = Query(default=24, ge=1, le=100),
    offset: int = Query(default=0, ge=0),
    db: Session = Depends(get_db),
) -> ProductPage:
    total, items = catalog_queries.list_product_variants(
        db,
        query=q,
        category=category,
        family=family,
        multi_only=multi_only,
        awarded_only=awarded_only,
        exclusives_only=exclusives_only,
        with_image_only=with_image_only,
        at_codes=[c for c in at if c] or None,
        sort=sort,
        limit=limit,
        offset=offset,
    )
    return ProductPage(total=total, limit=limit, offset=offset, items=items)


@router.get("/search/suggest", response_model=list[ProductSuggestion])
def search_suggest(
    q: str = Query(min_length=1, max_length=120),
    limit: int = Query(default=6, ge=1, le=10),
    db: Session = Depends(get_db),
) -> list[ProductSuggestion]:
    """Products for the header search as a shopper types. Airports and brands are
    matched in the browser from the lists it already holds (/api/airports,
    /api/brands); only product variants are too many to send."""
    return catalog_queries.suggest_product_variants(db, q, limit)


@router.get("/products/featured-savings", response_model=list[ProductSummary])
def featured_savings(
    at: list[str] = Query(
        default=[], max_length=12,
        description="Airport codes the shopper will pass through",
    ),
    total: int = Query(default=8, ge=1, le=12),
    db: Session = Depends(get_db),
) -> list[ProductSummary]:
    """The home page's lead cards, selected by the agreed featuring rules."""
    return catalog_queries.featured_savings(
        db, at_codes=[c for c in at if c] or None, total=total
    )


def _merged_redirect(request: Request, variant_id: int, resolved: int) -> RedirectResponse | None:
    """A merged product's API answers live under the survivor's id, by 301, so a
    cached link to the old id keeps working and clients learn the new one."""
    if resolved == variant_id:
        return None
    target = request.url.path.replace(f"/products/{variant_id}", f"/products/{resolved}", 1)
    if request.url.query:
        target += f"?{request.url.query}"
    return RedirectResponse(target, status_code=301)


@router.get("/products/{slug}", response_model=ProductLineDetail | ProductDetail)
def get_product(
    slug: str,
    request: Request,
    variant: str | None = Query(default=None, max_length=20, description="The product variant chosen within the line"),
    airports: str | None = Query(default=None, max_length=80, description="Airport codes the comparison uses, comma separated"),
    at: list[str] = Query(
        default=[], max_length=12,
        description="Airport codes the shopper will pass through (orders the medals; the variant read only)",
    ),
    db: Session = Depends(get_db),
):
    """The product line page's data (Stream K5): the line by its slug, `?variant=<id>&airports=LHR,CDG`.
    An alias line or a retired slug answers 301 to the line; a numeric slug is an old variant id and
    answers 301 to its line with the variant chosen. With LINE_PAGES=false the variant read answers
    as before."""
    if settings.line_pages:
        variant_id = int(variant) if variant and variant.isdigit() else None
        codes = urls.read_airports(airports)
        line, forward = catalog_queries.line_by_slug(db, slug)
        if line is not None:
            return catalog_queries.get_product_line(db, line.slug, variant_id, codes)
        if forward:
            return RedirectResponse("/api" + urls.line_path(forward, variant_id, codes), status_code=301)
        # An old variant slug (`<name>-<id>`, or the bare id) from a link the SPA still holds.
        old = urls.parse_product_slug(slug)
        landing = catalog_queries.line_of_variant(db, old) if old is not None else None
        if landing is None:
            raise HTTPException(status_code=404, detail={"error_code": "PRODUCT_LINE_NOT_FOUND"})
        return RedirectResponse("/api" + urls.line_path(landing[1], landing[0], codes), status_code=301)
    if not slug.isdigit():
        raise HTTPException(status_code=404, detail={"error_code": "PRODUCT_NOT_FOUND"})
    variant_id = int(slug)
    resolved = catalog_queries.resolve_variant_id(db, variant_id)
    if redirect := _merged_redirect(request, variant_id, resolved):
        return redirect
    product = catalog_queries.get_product(db, resolved, at_codes=[c for c in at if c] or None)
    if product is None:
        raise HTTPException(status_code=404, detail={"error_code": "PRODUCT_NOT_FOUND"})
    return product


@router.get("/products/{variant_id}/similar", response_model=list[ProductSummary])
def similar(
    variant_id: int,
    request: Request,
    limit: int = Query(default=8, ge=1, le=24),
    at: list[str] = Query(
        default=[], max_length=12,
        description="Airport codes the shopper will pass through",
    ),
    db: Session = Depends(get_db),
):
    resolved = catalog_queries.resolve_variant_id(db, variant_id)
    if redirect := _merged_redirect(request, variant_id, resolved):
        return redirect
    return catalog_queries.similar_product_variants(
        db, resolved, limit=limit, at_codes=[c for c in at if c] or None
    )


@router.get("/airports", response_model=list[AirportSummary])
def list_airports(db: Session = Depends(get_db)) -> list[AirportSummary]:
    """Every airport that has a page: visible storefronts we hold stock for."""
    return catalog_queries.list_airports(db)


@router.get("/airports/{iata}", response_model=AirportDetail)
def get_airport(
    iata: str,
    category: str | None = Query(default=None, max_length=80),
    multi_only: bool = False,
    awarded_only: bool = False,
    exclusives_only: bool = False,
    sort: str = Query(default="featured", pattern="^(featured|compared|price|name)$"),
    limit: int = Query(default=24, ge=1, le=100),
    offset: int = Query(default=0, ge=0),
    db: Session = Depends(get_db),
) -> AirportDetail:
    """The airport page's data: the same object the server renders the page from."""
    if len(iata) != 3 or not iata.isalpha():
        raise HTTPException(status_code=404, detail={"error_code": "AIRPORT_NOT_FOUND"})
    detail = catalog_queries.airport_detail(
        db, iata, category=category, multi_only=multi_only, awarded_only=awarded_only,
        exclusives_only=exclusives_only, sort=sort, limit=limit, offset=offset,
    )
    if detail is None:
        raise HTTPException(status_code=404, detail={"error_code": "AIRPORT_NOT_FOUND"})
    return detail


@router.get("/brands", response_model=list[BrandSummary])
def list_brands(db: Session = Depends(get_db)) -> list[BrandSummary]:
    """Every brand with a page: canonical rows with enough published product_variants."""
    return catalog_queries.list_brands(db)


@router.get("/brands/{slug}", response_model=BrandDetail)
def get_brand(
    slug: str,
    category: str | None = Query(default=None, max_length=80),
    sort: str = Query(default="featured", pattern="^(featured|compared|price|name)$"),
    limit: int = Query(default=24, ge=1, le=100),
    offset: int = Query(default=0, ge=0),
    db: Session = Depends(get_db),
) -> BrandDetail:
    """The brand page's data: the same object the server renders the page from.
    An alias slug answers with its brand (the page route 301s; the API just answers)."""
    detail = catalog_queries.brand_detail(
        db, slug.lower(), category=category, sort=sort, limit=limit, offset=offset
    )
    if detail is None:
        raise HTTPException(status_code=404, detail={"error_code": "BRAND_NOT_FOUND"})
    return detail


@router.get("/dataset", response_model=DatasetFacts)
def dataset(db: Session = Depends(get_db)) -> DatasetFacts:
    """The public data page's figures: the same object the server renders it from."""
    return catalog_queries.dataset_facts(db)


@router.get("/stats", response_model=StatsOut)
def stats(db: Session = Depends(get_db)) -> StatsOut:
    # Every figure is scoped to the shops the site shows (visible, not blocked),
    # so the home page's numbers always describe the site the visitor is looking at.
    multi = (
        select(Listing.variant_id)
        .join(Shop, Shop.id == Listing.shop_id)
        .where(Shop.is_catalogue_only.is_(False), catalog_queries.publishable(db))
        .group_by(Listing.variant_id)
        .having(func.count(func.distinct(Listing.shop_id)) > 1)
        .subquery()
    )
    visible_listing = (
        select(Listing.id, Listing.variant_id, Listing.shop_id)
        .join(Shop, Shop.id == Listing.shop_id)
        .where(catalog_queries.publishable(db))
        .subquery()
    )
    return StatsOut(
        product_variants=db.scalar(
            select(func.count(func.distinct(visible_listing.c.variant_id)))
        ) or 0,
        product_variants_multi_shop=db.scalar(select(func.count()).select_from(multi)) or 0,
        # Only shops we actually hold stock for. A configured-but-empty store
        # (one whose site we cannot read yet) is coverage we do not have, and
        # counting it would overstate the catalogue on the home page.
        shops=db.scalar(
            select(func.count(func.distinct(visible_listing.c.shop_id)))
        ) or 0,
        retailers=db.scalar(select(func.count(Retailer.id))) or 0,
        observations=db.scalar(
            select(func.count(PriceObservation.id)).where(
                PriceObservation.listing_id.in_(select(visible_listing.c.id))
            )
        ) or 0,
        awards=db.scalar(
            select(func.count(Award.id)).where(
                Award.variant_id.in_(select(visible_listing.c.variant_id))
            )
        ) or 0,
        last_collected_at=db.scalar(
            select(func.max(PriceObservation.observed_at)).where(
                PriceObservation.listing_id.in_(select(visible_listing.c.id))
            )
        ),
        categories=catalog_queries.category_counts(db),
    )
