"""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",
    "HEL": "helsinki-vantaa-hel",
    "HKG": "hong-kong-hkg",
    "ICN": "incheon-icn-seoul",
    "JFK": "jfk-new-york",
    "KEF": "keflavik-kef-reykjavik",
    "LAS": "las-vegas-las",
    "LGW": "gatwick-lgw-london",
    "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()}


#: The family's word in an address, keyed by the taxonomy's vertical. Declared data like
#: CATEGORY_SLUGS, mirrored EXACTLY by lib/urls.ts FAMILY_SLUGS (tests/test_category_pages.py
#: reads that file). The word is the shopper's search term, not our column name ("alcohol",
#: not "liquor"), which is the rule the structure review settled: a generic word is dropped
#: only where it displaces what people search. A family with no entry here has no landing
#: page and none of its categories has one either, which is how a family the storefront does
#: not show as a department stays off the site.
FAMILY_SLUGS: dict[str, str] = {
    "liquor": "alcohol",
    "beauty": "beauty",
}
_FAMILY_OF_SLUG = {slug: family for family, slug in FAMILY_SLUGS.items()}


def family_slug(family: str | None) -> str | None:
    return FAMILY_SLUGS.get(family or "")


def family_from_slug(slug: str | None) -> str | None:
    """The family a landing address names, exactly; case folded, nothing else forgiven."""
    return _FAMILY_OF_SLUG.get((slug or "").lower())


def family_path(family: str) -> str:
    """`/alcohol`: the family's landing page. Raises for a family with no word, because
    such a family has no page. Mirrors lib/urls.ts familyPagePath."""
    slug = family_slug(family)
    if slug is None:
        raise ValueError(f"no address for the family {family!r}")
    return f"/{slug}"


def category_page_path(family: str, category: str) -> str:
    """`/alcohol/whisky`: the family's word, then the category's own. Nested under the
    family rather than at the root, where a category word could collide with a brand name
    (the structure review, 10 Sep). Raises when either half has no word. Mirrors
    lib/urls.ts categoryPagePath."""
    return f"{family_path(family)}/{_require_category_slug(category)}"


def _require_category_slug(category: str) -> str:
    slug = category_slug(category)
    if slug is None:
        raise ValueError(f"no address for the category {category!r}")
    return slug


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, family: str | None = None) -> str:
    """Where a category name links. Its own page once both words are declared, else the
    catalogue filtered to it, which is all there was before the category page existed. The
    family is looked up when the caller does not know it, so no caller has to. Mirrors
    lib/urls.ts categoryPath."""
    from app.services import taxonomy  # lazy: taxonomy is data, and this module stays importable alone

    resolved = family or taxonomy.vertical_of(category)
    if resolved and family_slug(resolved) and category_slug(category) and taxonomy.is_shown(category):
        return category_page_path(resolved, category)
    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="-_.!~*'()")
