"""The site's URL shapes, in one place.

Mirrored by `web/src/lib/urls.ts`: the two must agree or server canonicals
fight client links. Kept out of `seo.py` so the query layer can build a page's
path without importing the renderer (which imports the query layer).
"""

import re
import unicodedata
from urllib.parse import quote

_SLUG_RE = re.compile(r"[^a-z0-9]+")
_IATA_RE = re.compile(r"^[a-z]{3}$")


def _slug(text: str | None) -> str:
    cleaned = unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode()
    return _SLUG_RE.sub("-", cleaned.lower()).strip("-")


def slugify(text: str) -> str:
    """ASCII slug for URLs. Must stay trivial: the frontend mirrors it."""
    return _slug(text) or "product"


def product_path(variant_id: int, name: str) -> str:
    return f"/products/{slugify(name)}-{variant_id}"


def line_path(slug: str, variant_id: int | None = None, airports: list[str] | tuple[str, ...] | None = None) -> str:
    """The product line page (plan W1): `/products/<line-slug>`, a variant within it as
    `?variant=<id>`, the airports its comparison uses as `&airports=LHR,CDG`. The bare address
    is the canonical of every view. Mirrors lib/urls.ts linePath."""
    bits = []
    if variant_id is not None:
        bits.append(f"variant={variant_id}")
    codes = [c.upper() for c in (airports or []) if c]
    if codes:
        bits.append("airports=" + ",".join(codes))
    return f"/products/{slug}" + ("?" + "&".join(bits) if bits else "")


_AIRPORT_CODE_RE = re.compile(r"^[A-Z]{3}$")


def read_airports(value: str | None, limit: int = 12) -> list[str]:
    """The `airports` parameter read leniently: comma separated, upper-cased, three letters each,
    duplicates dropped in order, at most `limit`; anything else is ignored, never an error."""
    out: list[str] = []
    for part in (value or "").split(","):
        code = part.strip().upper()
        if _AIRPORT_CODE_RE.match(code) and code not in out:
            out.append(code)
    return out[:limit]


def parse_product_slug(slug: str) -> int | None:
    """The trailing number is the identity; the words are for humans."""
    match = re.search(r"(\d+)$", slug)
    return int(match.group(1)) if match else None


#: The airport's common name for its address, keyed by IATA code. What people
#: search for leads ("heathrow" is overwhelmingly the query, not "lhr" or
#: "london airport"), then the code, then the city unless the name already
#: says it. JFK is the one airport known by its code. Declared data: the words
#: are for people and never come from a request string; the code stays the
#: identity. Mirrored EXACTLY by lib/urls.ts AIRPORT_SLUGS
#: (tests/test_seo_airport.py::TestAirportSlugTable reads that file). An airport
#: with no entry falls back to `<iata>-<city>` so nothing 404s.
AIRPORT_SLUGS: dict[str, str] = {
    "ATH": "athens-ath",
    "BCN": "barcelona-el-prat-bcn",
    "BOG": "bogota-el-dorado-bog",
    "CDG": "paris-cdg",
    "DUB": "dublin-dub",
    "DXB": "dubai-dxb",
    "EZE": "buenos-aires-ezeiza-eze",
    "HKG": "hong-kong-hkg",
    "ICN": "incheon-icn-seoul",
    "JFK": "jfk-new-york",
    "KEF": "keflavik-kef-reykjavik",
    "LHR": "heathrow-lhr-london",
    "MAD": "madrid-barajas-mad",
    "MEX": "mexico-city-mex",
    "PTY": "tocumen-pty-panama-city",
    "SAL": "san-salvador-sal",
    "SIN": "changi-sin-singapore",
    "SYD": "sydney-syd",
    "YUL": "montreal-trudeau-yul",
    "YYZ": "toronto-pearson-yyz",
    "ZRH": "zurich-zrh",
}
_IATA_OF_SLUG = {slug: iata for iata, slug in AIRPORT_SLUGS.items()}


def airport_path(iata: str, city: str | None, name: str | None = None) -> str:
    """`/airports/<common-name>-<iata>-<city>` from the declared table (the
    review's shape: the airport's name leads, JFK code-first), else
    `/airports/<iata>-<city>` for an airport nobody has named yet. The IATA
    code is the identity either way; the words come from the table or the
    `shops` row, never from a string a request sent."""
    code = iata.upper()
    declared = AIRPORT_SLUGS.get(code)
    if declared:
        return f"/airports/{declared}"
    words = _slug(city or name)
    return f"/airports/{code.lower()}-{words}" if words else f"/airports/{code.lower()}"


def parse_airport_slug(slug: str) -> str | None:
    """Which airport a slug means, so every shape ever linked still lands:
    (1) a declared slug, exactly; (2) a declared code leading the slug (the
    pre-review `lhr-london` shape, the bare `lhr`); (3) a declared code in any
    later segment (a stale new-shape slug, `heathrow-lhr-terminal-5`); (4) an
    undeclared airport in the old shape, its code leading. A three-letter word
    is only ever read as a code when it leads or is declared: `san-salvador-sal`
    resolves to SAL by (1), and a stale `san-salvador-sal-x` by (3), never to a
    San Diego that was never in the table. The caller 301s anything that is
    not the canonical path; an unknown airport is None."""
    lowered = slug.lower()
    declared = _IATA_OF_SLUG.get(lowered)
    if declared:
        return declared
    segments = lowered.split("-")
    head = segments[0]
    if head.upper() in AIRPORT_SLUGS:
        return head.upper()
    for segment in segments[1:]:
        if segment.upper() in AIRPORT_SLUGS:
            return segment.upper()
    return head.upper() if _IATA_RE.match(head) else None


AIRPORT_TABS = ("value", "exclusives", "all")

#: The shopping-feature filters of the airport page's full list, named exactly as
#: `/api/products` accepts them, in chip order. Mirrored by lib/urls.ts AIRPORT_FEATURE_PARAMS.
FEATURE_PARAMS = ("multi_only", "awarded_only", "exclusives_only")


def feature_flags(**values: bool) -> dict[str, bool]:
    """The features that are on, as `airport_query` and `airport_tab` take them."""
    return {name: True for name in FEATURE_PARAMS if values.get(name)}


def read_flag(value: str | None) -> bool:
    """A query flag read leniently, as FastAPI reads a bool: true, 1, yes, on."""
    return (value or "").strip().lower() in ("1", "true", "yes", "on")


def airport_tab(
    tab: str | None, category: str | None, sort: str, page: int, features: dict[str, bool] | None = None
) -> str:
    """Which of the airport page's shelves a URL is asking for. A filter (a category or a
    shopping feature), a sort or a page can only mean the full list. Mirrors lib/urls.ts airportTab."""
    if tab in AIRPORT_TABS:
        return tab
    if category or (sort and sort != "featured") or page > 1 or any((features or {}).values()):
        return "all"
    return "value"


def airport_query(
    category: str | None, sort: str, page: int, tab: str = "value", features: dict[str, bool] | None = None
) -> str:
    """The query string of an airport page's filtered view, defaults omitted so
    the clean page has no query at all. Mirrors lib/urls.ts airportQuery."""
    bits = []
    if tab != "value":
        bits.append("tab=" + tab)
    if category:
        bits.append("category=" + urlquote(category))
    for name in FEATURE_PARAMS:
        if features and features.get(name):
            bits.append(f"{name}=true")
    if sort and sort != "featured":
        bits.append("sort=" + urlquote(sort))
    if page > 1:
        bits.append(f"page={page}")
    return "?" + "&".join(bits) if bits else ""


#: The category's word in an address, keyed by the taxonomy's category name. Declared data
#: (the review: no generic word where it displaces the search term, so "whisky", never
#: "categories/whisky"), mirrored EXACTLY by lib/urls.ts CATEGORY_SLUGS
#: (tests/test_airport_category.py reads that file). A category with no entry has no page.
CATEGORY_SLUGS: dict[str, str] = {
    "Whisky": "whisky",
    "Cognac & Brandy": "cognac-brandy",
    "Tequila & Mezcal": "tequila-mezcal",
    "Rum": "rum",
    "Gin": "gin",
    "Vodka": "vodka",
    "Champagne & Sparkling": "champagne-sparkling",
    "Wine": "wine",
    "Beer & Cider": "beer-cider",
    "Liqueurs & Aperitifs": "liqueurs-aperitifs",
    "Perfume": "perfume",
    "Makeup": "makeup",
    "Skincare": "skincare",
    "Confectionery": "confectionery",
    "Tobacco": "tobacco",
}
_CATEGORY_OF_SLUG = {slug: category for category, slug in CATEGORY_SLUGS.items()}


def category_slug(category: str | None) -> str | None:
    return CATEGORY_SLUGS.get(category or "")


def category_from_slug(slug: str | None) -> str | None:
    """The category a pair address names, exactly; case folded, nothing else forgiven."""
    return _CATEGORY_OF_SLUG.get((slug or "").lower())


def airport_category_path(airport_path_: str, category: str) -> str:
    """`/airports/<airport>/<category-slug>`: the airport's own address, then the category's
    word (`lib/structure.ts`: /airports/heathrow-lhr-london/whisky). Mirrors lib/urls.ts
    airportCategoryPath. Raises for a category with no slug: such a pair has no page."""
    slug = category_slug(category)
    if slug is None:
        raise ValueError(f"no address for the category {category!r}")
    return f"{airport_path_}/{slug}"


def savings_from_path(iata: str) -> str:
    """The comparison tool opened on one airport: `/savings?from=<IATA>`. The page reads the
    code once and holds it as its view, beside the shopper's saved airports, never writing it
    into that set. Mirrors lib/urls.ts savingsFromPath."""
    return f"/savings?from={iata.upper()}"


def brand_page_path(slug: str) -> str:
    """`/brands/<slug>`: the slug is the brands table's (`Brand.slug`), never a
    string folded from a name here. Mirrors lib/urls.ts brandPagePath."""
    return f"/brands/{slug}"


def brand_path(brand: str, slug: str | None = None) -> str:
    """Where a brand name links: its page when the API handed us the brand's
    slug, else a catalogue search. Mirrors lib/urls.ts brandPath."""
    if slug:
        return brand_page_path(slug)
    return "/products?q=" + urlquote(brand)


def category_path(category: str) -> str:
    """Mirrors lib/urls.ts categoryPath."""
    return "/products?category=" + urlquote(category)


def urlquote(value: str) -> str:
    # Same output as JavaScript's encodeURIComponent for the characters that
    # matter, so server links and client links are byte-identical.
    return quote(value, safe="-_.!~*'()")
