"""Airport pages: the URL shape, the rendered body and the structured data.

The first hub page type (build plan §6, B4). The page is keyed by the IATA code
on the `locations` row; the words after it are for humans and may go stale.
Everything the page shows is counted in the database, so these tests build one
AirportDetail by hand and pin what the renderer must say about it: the H1, the
counts, the product cards with their prices and links, CollectionPage about an
Airport with an ItemList, breadcrumbs, and a canonical that ignores filters.
No database.
"""

import json
import pathlib
import re
from datetime import UTC, datetime

import pytest

from app.models.hubs import AirportDetail, AirportShop
from app.models.schemas import CategoryCount, ProductSummary, ShopPrice, TopAward
from app.services import airport_guides, seo, urls

CHECKED = datetime(2026, 8, 25, 23, 6, tzinfo=UTC)
SHELL = (
    "<!doctype html><html><head><title>x</title>"
    '<meta name="description" content="old" /></head>'
    '<body><div id="root"></div></body></html>'
)


def summary(**over) -> ProductSummary:
    base = dict(
        id=1156, name="Santa Teresa 1796 Solera Rum 1L", brand="Santa Teresa", category="Rum",
        size_ml=1000, abv=40.0, is_exclusive=False, thumb_url="https://img.example/1156.jpg",
        location_count=3, best_location="London Heathrow", best_location_iata="LHR",
        location_labels=["LHR", "JFK", "EZE"],
        top_prices=[ShopPrice(label="LHR", usd=38.0, in_stock=True),
                    ShopPrice(label="JFK", usd=45.0, in_stock=None),
                    ShopPrice(label="EZE", usd=55.5, in_stock=True)],
        top_award=TopAward(competition_slug="nyisc", competition="NYISC", medal="Double Gold", year=2024),
        cheapest_usd=38.0, dearest_usd=55.5, award_count=1,
    )
    base.update(over)
    return ProductSummary(**base)


def airport(**over) -> AirportDetail:
    base = dict(
        iata="LHR", path="/airports/heathrow-lhr-london", name="London Heathrow", city="London",
        country="United Kingdom", currency="GBP", products=266, last_collected_at=CHECKED,
        shops=[AirportShop(code="LHR", retailer_name="Avolta", products=266, last_collected_at=CHECKED)],
        comparable=108, cheapest_here=67, exclusives=8,
        categories=[CategoryCount(category="Whisky", count=67), CategoryCount(category="Gin & <Tonic>", count=24)],
        savings=[summary()],
        exclusive_items=[summary(id=7, name="Talisker Dark Storm 1L", is_exclusive=True, award_count=0,
                                 top_award=None, location_count=1, top_prices=[], thumb_url=None,
                                 cheapest_usd=60.0, dearest_usd=60.0)],
        total=266, limit=24, offset=0,
        items=[summary(), summary(id=8, name="Bell's Blended Scotch 1l", award_count=0, top_award=None)],
    )
    base.update(over)
    return AirportDetail(**base)


class TestAirportUrls:
    def test_declared_airport_leads_with_its_common_name(self):
        """Mark's review of 7 Sep: "heathrow" is overwhelmingly what is
        searched, so the name leads, then the code, then the city; JFK is the
        one airport known by its code. The pages had shipped code-first four
        days earlier; nothing was indexed, so the cost was a rename plus
        redirects."""
        assert urls.airport_path("LHR", "London") == "/airports/heathrow-lhr-london"
        assert urls.airport_path("JFK", "New York", "New York JFK") == "/airports/jfk-new-york"
        # The city is dropped where the name already says it.
        assert urls.airport_path("HKG", "Hong Kong", "Hong Kong International") == "/airports/hong-kong-hkg"
        # Paris is CDG and Orly together, named for Paris.
        assert urls.airport_path("CDG", "Paris", "Paris Charles de Gaulle and Orly") == "/airports/paris-cdg"
        assert urls.airport_path("DUB", "Dublin", "Dublin & Cork") == "/airports/dublin-dub"
        # Case of the code does not matter.
        assert urls.airport_path("lhr", "London") == "/airports/heathrow-lhr-london"

    def test_undeclared_airport_keeps_the_old_shape_so_nothing_404s(self):
        assert urls.airport_path("XYZ", "Nowhere") == "/airports/xyz-nowhere"
        assert urls.airport_path("XYZ", None, "Some Name") == "/airports/xyz-some-name"
        assert urls.airport_path("QQQ", None, None) == "/airports/qqq"

    def test_every_shape_ever_linked_resolves_to_its_airport(self):
        """Every earlier shape must still land, the bare code, the
        pre-review `lhr-london`, a stale new-shape slug, because the route
        301s from whatever resolves."""
        for slug in (
            "heathrow-lhr-london", "lhr", "LHR", "LHR-Heathrow", "lhr-london",
            "lhr-london-airport", "heathrow-lhr-terminal-5", "london-lhr",
        ):
            assert urls.parse_airport_slug(slug) == "LHR", slug
        assert urls.parse_airport_slug("jfk-new-york") == "JFK"
        assert urls.parse_airport_slug("jfk") == "JFK"
        # Undeclared old shape.
        assert urls.parse_airport_slug("xyz-nowhere") == "XYZ"
        for slug in ("nope", "12a-x", "lh", "lhrx", "", "athens", "london"):
            assert urls.parse_airport_slug(slug) is None, slug

    def test_a_three_letter_word_is_only_a_code_when_it_leads_or_is_declared(self):
        """San Salvador's slug starts with a three-letter word that is also
        San Diego's code; it must resolve to SAL, exactly and when stale,
        never to an airport that was never in the table."""
        assert urls.parse_airport_slug("san-salvador-sal") == "SAL"
        assert urls.parse_airport_slug("san-salvador-sal-stale") == "SAL"

    def test_no_declared_slug_collides_with_another_declared_code(self):
        for iata, slug in urls.AIRPORT_SLUGS.items():
            assert re.fullmatch(r"[A-Z]{3}", iata)
            assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)
            assert urls.parse_airport_slug(slug) == iata
            head = slug.split("-")[0]
            # Only a true collision risk when the leading word is itself a
            # declared code (SAL's slug leads with "san", San Diego's code,
            # but SAN is undeclared, so parse_airport_slug never sees it as
            # a match; see test_a_three_letter_word_is_only_a_code...).
            if len(head) == 3 and head.upper() in urls.AIRPORT_SLUGS:
                assert head.upper() == iata, f"{slug} leads with a code that is not its own"
            assert iata.lower() in slug.split("-")
        assert len(set(urls.AIRPORT_SLUGS.values())) == len(urls.AIRPORT_SLUGS)

    def test_query_omits_defaults_so_the_clean_page_has_none(self):
        assert urls.airport_query(None, "featured", 1) == ""
        assert urls.airport_query("Gin & Tonic", "featured", 1) == "?category=Gin%20%26%20Tonic"
        assert urls.airport_query(None, "price", 3) == "?sort=price&page=3"

    def test_seo_module_still_exports_the_helpers(self):
        """Routes and older tests import the URL helpers from seo; the move to
        urls.py must not break that."""
        assert seo.product_path(1, "x") == "/products/x-1" and seo.airport_path("LHR", "London") == "/airports/heathrow-lhr-london"


class TestAirportSlugTable:
    def test_ts_mirror_holds_the_same_table(self):
        """The two tables must agree or server canonicals fight client
        links. Read the SPA's own file the way TestRouteInventory reads
        App.tsx."""
        src = (pathlib.Path(__file__).resolve().parents[1] / "web" / "src" / "lib" / "urls.ts").read_text()
        start = src.index("export const AIRPORT_SLUGS")
        block = src[start:src.index("};", start)]
        entries = re.findall(r'^\s+([A-Z]{3}): "([a-z0-9-]+)",$', block, re.M)
        ts_table = dict(entries)
        assert ts_table
        assert ts_table == urls.AIRPORT_SLUGS


class TestAirportHead:
    def test_title_description_and_canonical(self):
        head = seo.head_for_airport(airport())
        assert head.title == "London Heathrow (LHR) duty-free prices | Duty Free Professor"
        assert head.description == (
            "266 duty-free products at London Heathrow (LHR), checked 25 Aug 2026. "
            "108 sold at other airports too: see where London is the better buy."
        )
        assert head.canonical_path == "/airports/heathrow-lhr-london"
        assert head.last_modified == CHECKED
        assert head.seed_key == "__DFP_AIRPORT__" and head.seed["iata"] == "LHR"

    def test_a_filtered_or_paged_view_keeps_the_clean_canonical(self):
        head = seo.head_for_airport(airport(category="Whisky", sort="price", offset=24))
        assert head.canonical_path == "/airports/heathrow-lhr-london"
        assert not head.noindex

    def test_collection_page_about_an_airport_with_an_item_list(self):
        page = seo.head_for_airport(airport()).apply(SHELL, "https://s.example")
        blocks = [json.loads(m) for m in re.findall(r'<script type="application/ld\+json">(.*?)</script>', page)]
        collection = next(b for b in blocks if b["@type"] == "CollectionPage")
        assert collection["@id"] == "https://s.example/airports/heathrow-lhr-london#page"
        assert collection["url"] == "https://s.example/airports/heathrow-lhr-london"
        assert collection["isPartOf"] == {"@id": "https://s.example/#website"}
        about = collection["about"]
        assert about["@type"] == "Airport" and about["iataCode"] == "LHR" and about["name"] == "London Heathrow"
        # Only what the locations row holds: no street, no coordinates.
        assert about["address"] == {"@type": "PostalAddress", "addressLocality": "London", "addressCountry": "United Kingdom"}
        assert "geo" not in about and "streetAddress" not in about["address"]
        items = collection["mainEntity"]
        assert items["@type"] == "ItemList" and items["numberOfItems"] == 266
        assert items["itemListElement"][0] == {
            "@type": "ListItem", "position": 1,
            "url": "https://s.example/products/santa-teresa-1796-solera-rum-1l-1156",
            "name": "Santa Teresa 1796 Solera Rum 1L",
        }
        crumbs = next(b for b in blocks if b["@type"] == "BreadcrumbList")
        assert crumbs["itemListElement"][0]["item"] == "https://s.example/airports"
        assert crumbs["itemListElement"][1] == {"@type": "ListItem", "position": 2, "name": "London Heathrow"}

    def test_positions_continue_across_pages(self):
        head = seo.head_for_airport(airport(offset=24))
        positions = [e["position"] for e in head.jsonld[0]["mainEntity"]["itemListElement"]]
        assert positions == [25, 26]

    def test_an_airport_without_a_city_or_country_has_no_address(self):
        head = seo.head_for_airport(airport(city=None, country=None, path="/airports/lhr"))
        assert "address" not in head.jsonld[0]["about"]


class TestAirportBody:
    def test_the_page_reads_without_javascript(self):
        body = seo.airport_body(airport())
        assert '<h1 class="airports-hero__title">Duty free at London Heathrow</h1>' in body
        assert "266 duty-free products priced by Avolta at London Heathrow (LHR), checked 25 Aug 2026." in body
        for label, value in (("Products priced", "266"), ("Sold elsewhere too", "108"),
                             ("Cheapest here", "67"), ("Travel exclusives", "8")):
            assert f"<dt>{label}</dt><dd>{value}</dd>" in body, label
        assert '<div class="airport-fact"><dt>Shops we read</dt><dd>Avolta (266 products)</dd></div>' in body
        assert '<div class="airport-fact"><dt>Prices checked</dt><dd>25 Aug 2026</dd></div>' in body
        assert 'href="/airports">Airports we price</a>' in body

    def test_the_three_shelves_are_tabs_and_the_page_opens_on_the_savings(self):
        """The page used to stack four grids of cards; it now shows one shelf at
        a time, the shelf in the URL, so a shopper scrolls one list, not four."""
        body = seo.airport_body(airport())
        assert '<nav class="shelf-tabs" aria-label="Products at this airport">' in body
        assert ('<a href="/airports/heathrow-lhr-london#products" class="shelf-tab shelf-tab--active" '
                'aria-current="page">Best value here<span class="shelf-tab__count">67</span></a>') in body
        assert ('<a href="/airports/heathrow-lhr-london?tab=exclusives#products" class="shelf-tab">'
                'Travel exclusives<span class="shelf-tab__count">8</span></a>') in body
        assert ('<a href="/airports/heathrow-lhr-london?tab=all#products" class="shelf-tab">'
                'All products<span class="shelf-tab__count">266</span></a>') in body
        assert "LHR is the cheapest of our airports for 67 of the 108 products sold at more than one." in body
        # The savings are the shelf on show; the full list and its sort are not.
        assert "collection-bar__sort" not in body
        assert body.count('class="product-card"') == len(airport().savings)

    def test_another_shelf_shows_its_own_cards_and_its_own_line(self):
        body = seo.airport_body(airport(), tab="exclusives")
        assert 'class="shelf-tab shelf-tab--active" aria-current="page">Travel exclusives' in body
        assert "Bottlings and editions sold only in travel retail, as we find them at London Heathrow." in body
        assert '<a href="/exclusives" class="btn btn--ghost">All exclusives</a>' in body
        assert "Talisker Dark Storm 1L" in body and "Bell's Blended Scotch 1l" not in body

    def test_the_full_list_carries_the_chips_the_sort_and_the_pager(self):
        body = seo.airport_body(airport(), tab="all")
        assert 'class="shelf-tab shelf-tab--active" aria-current="page">All products' in body
        assert "Everything we price at London Heathrow, shop by shop." in body
        assert '<div class="filter-rail">' in body and "collection-bar__sort" in body

    def test_cards_carry_the_name_the_prices_and_the_link(self):
        body = seo.airport_body(airport(), medal_assets={"nyisc-double-gold.png"}) + seo.airport_body(
            airport(), medal_assets={"nyisc-double-gold.png"}, tab="exclusives"
        )
        assert 'href="/products/santa-teresa-1796-solera-rum-1l-1156" class="product-card"' in body
        assert '<span class="product-card__name">Santa Teresa 1796 Solera Rum 1L</span>' in body
        assert '<span class="product-card__meta">In 3 shops · 1L · 40%</span>' in body
        # The per-shop bars, cheapest first, scaled to the dearest.
        assert 'class="mini-compare__row mini-compare__row--best" style="--bar: 68%;"' in body
        assert '<span class="mini-compare__price">$55.50</span>' in body
        assert '<span class="product-card__save">Save $17.50</span><span class="product-card__buyat">buy at LHR</span>' in body
        # Medal artwork where it exists; the text tag where an exclusive has no medal.
        assert 'class="product-card__medal" src="/medals/nyisc-double-gold.png?v=2" alt="Double Gold, NYISC 2024"' in body
        assert '<span class="product-card__tag product-card__tag--excl">Exclusive</span>' in body
        assert '<span class="product-card__meta">Travel-retail exclusive · 1L · 40%</span>' in body

    def test_without_the_artwork_the_award_becomes_a_text_tag(self):
        body = seo.airport_body(airport(exclusive_items=[], items=[summary()]))
        assert '<span class="product-card__tag product-card__tag--medal">Award winner</span>' in body
        assert "product-card__medal" not in body

    def test_category_chips_link_to_the_filtered_view_and_escape(self):
        body = seo.airport_body(airport(), tab="all")
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky#products">Whisky <span class="chip-count">67</span>' in body
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;category=Gin%20%26%20%3CTonic%3E#products">Gin &amp; &lt;Tonic&gt;' in body

    def test_the_active_chip_links_back_to_the_unfiltered_list(self):
        """A filter can only mean the full list, so it selects that shelf on its
        own; the active chip clears the filter and keeps the shelf."""
        body = seo.airport_body(airport(category="Whisky", sort="price"), tab="all")
        assert ('class="filter-chip filter-chip--active" '
                'href="/airports/heathrow-lhr-london?tab=all&amp;sort=price#products">Whisky') in body
        assert "Whisky priced at London Heathrow, cheapest first where we can compare." in body

    def test_pager_links_keep_the_filters_and_disable_at_the_ends(self):
        first = seo.airport_body(airport(total=67, category="Whisky"), tab="all")
        assert ('href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky#products" '
                'aria-disabled="true" tabindex="-1">Previous</a>') in first
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky&amp;page=2#products">Next</a>' in first
        assert '<span class="pager__status">1-24 of 67</span>' in first
        last = seo.airport_body(airport(total=67, offset=48), tab="all")
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;page=2#products">Previous</a>' in last
        assert ('href="/airports/heathrow-lhr-london?tab=all&amp;page=4#products" '
                'aria-disabled="true" tabindex="-1">Next</a>') in last
        assert '<span class="pager__status">49-67 of 67</span>' in last
        assert "pager" not in seo.airport_body(airport(total=24), tab="all")

    def test_a_shelf_with_nothing_on_it_is_not_offered(self):
        """An airport with no savings and no exclusives has one shelf, the full
        list, and the page falls back to it rather than showing an empty tab."""
        body = seo.airport_body(airport(savings=[], exclusive_items=[], categories=[], items=[], total=0, comparable=0))
        assert "Best value here" not in body and "Travel exclusives<span" not in body
        assert 'class="shelf-tab shelf-tab--active" aria-current="page">All products' in body
        assert '<div class="empty-state">Nothing here matches that category yet.</div>' in body
        assert "<dt>Sold elsewhere too</dt><dd>0</dd>" in body

    def test_the_add_button_follows_the_feature_flag(self):
        assert "Add to my airports" in seo.airport_body(airport(), flags={"myAirports": True})
        assert "Add to my airports" not in seo.airport_body(airport(), flags={"myAirports": False})

    def test_untrusted_names_are_escaped_in_markup_and_json(self):
        evil = airport(name='Heathrow <b>"T5"</b>', items=[summary(name="Rum </script><script>alert(1)")])
        page = seo.head_for_airport(evil).apply(SHELL, "https://s.example")
        assert "<b>" not in page.split("<h1")[1].split("</h1>")[0]
        assert "</script><script>alert(1)" not in page
        assert "\\u003c/script" in page

    def test_no_em_dash_in_what_the_page_says(self):
        """House style for client-facing text."""
        assert "—" not in seo.airport_body(airport())
        head = seo.head_for_airport(airport())
        assert "—" not in head.title and "—" not in head.description


class TestFormatMirrors:
    def test_date_is_utc_and_matches_format_ts(self):
        assert seo.fmt_date(CHECKED) == "25 Aug 2026"
        assert seo.fmt_date(datetime(2026, 9, 5, 0, 30, tzinfo=UTC)) == "5 Sep 2026"
        assert seo.fmt_date(None) == "not yet"

    @pytest.mark.parametrize("value,expected", [(0.5, 1), (1.5, 2), (2.5, 3), (67.4, 67), (67.5, 68)])
    def test_bar_widths_round_like_math_round(self, value, expected):
        assert seo._js_round(value) == expected


class TestAirportGuide:
    """The written guide to where an airport's duty free is
    (`services.airport_guides`), which the SPA and the crawler's body draw from
    the same fields (`components/AirportGuide.tsx`, `seo.airport_guide_html`)."""

    def test_an_airport_nobody_has_written_has_no_guide_and_no_section(self):
        """Empty beats guessed: an airport with no written guide simply has no
        guide section, rather than a heading over nothing."""
        assert airport_guides.guide_for("QQQ") is None
        body = seo.airport_body(airport(guide=None))
        assert "airport-terminals" not in body and "Where to shop at" not in body
        # The facts panel stays: the shops and the date are ours, not written.
        assert '<div class="airport-fact"><dt>Shops we read</dt>' in body

    def test_the_guide_draws_a_row_per_terminal_with_its_specialty_shops(self):
        body = seo.airport_body(airport(guide=airport_guides.guide_for("LHR")))
        assert '<h2 class="section-heading">Where to shop at London Heathrow</h2>' in body
        assert body.count('<details class="terminal">') == 4
        assert '<span class="terminal__name">Terminal 5</span>' in body
        assert '<span class="terminal__airlines">British Airways and Iberia</span>' in body
        assert "flagship Harrods near the B Gates" in body
        # A terminal with no boutiques worth naming draws no specialty line.
        assert body.count('class="terminal__specialty"') == 3
        # The operator and the terminal count are facts, not prose.
        assert '<div class="airport-fact"><dt>Duty free operator</dt><dd>World Duty Free (Avolta)</dd></div>' in body
        assert '<div class="airport-fact"><dt>Terminals</dt><dd>4</dd></div>' in body

    def test_a_service_and_the_checked_date_ride_under_the_terminals(self):
        body = seo.airport_body(airport(guide=airport_guides.guide_for("LHR")))
        assert '<p class="airport-terminals__service"><strong>Reserve &amp; Collect</strong>' in body
        assert "Shop locations checked September 2026" in body
        # Nothing is claimed that nobody wrote: no map, no hours for LHR yet.
        assert "airport-facts__map" not in body
        assert "Opening hours" not in body

    def test_the_placeholder_view_never_reaches_the_server_body(self):
        """The demo's placeholder cards are for us. A crawler must never see a
        box saying what the page is missing, and the structure proposal's rule
        is that there is no filler of any kind, least of all for search."""
        body = seo.airport_body(airport(guide=None))
        assert "missing" not in body and "What we need" not in body
