"""The server-rendered product body.

Until 2026-09-04 a product page's served HTML was `<div id="root"></div>`: zero
bytes of visible text for any client that does not run JavaScript, which is
every AI assistant's fetcher and most crawlers' first pass. The body now
mirrors ProductPage.tsx, so these tests pin the two renders to each other: the
formatting helpers must produce what lib/format.ts produces, and the markup must
carry the name, the prices and the links, escaped.
"""

import json
import re
from datetime import UTC, datetime, timedelta

from app.models.schemas import AwardOut, PriceOut, ProductDetail
from app.services import seo

NOW = datetime(2026, 9, 5, 12, 0, tzinfo=UTC)
SHELL = (
    "<!doctype html><html><head><title>x</title>"
    '<meta name="description" content="old" /></head>'
    '<body><div id="root"></div></body></html>'
)


def price(**over) -> PriceOut:
    base = dict(
        shop_code="lhr", shop_name="London Heathrow", shop_iata="LHR",
        shop_path="/airports/heathrow-lhr-london",
        retailer_name="World Duty Free", is_catalogue_only=False, currency="GBP",
        price=52.99, price_usd=67.10, price_type="duty_free", in_stock=True,
        observed_at=NOW - timedelta(hours=5), url="https://shop.example/x",
    )
    base.update(over)
    return PriceOut(**base)


def detail(**over) -> ProductDetail:
    base = dict(
        id=1156, name="Santa Teresa 1796 Ron Antiguo de Solera 70cl", brand="Santa Teresa",
        gtin="7591174000018", category="Rum", quantity_ml=700, abv=40.0, is_exclusive=False,
        image_url="https://img.example/1156.jpg", image_source="Open Food Facts",
        country_of_origin="Venezuela", shop_count=3,
        cheapest_usd=38.0, dearest_usd=55.5, award_count=1,
        prices=[
            price(price_usd=38.0, price=140.0, currency="AED", shop_code="dxb",
                  shop_name="Dubai", shop_iata="DXB", shop_path="/airports/dubai-dxb",
                  retailer_name="Dubai Duty Free"),
            price(price_usd=55.5),
        ],
        awards=[AwardOut(competition="NYISC", competition_slug="nyisc", year=2024, medal="Double Gold")],
    )
    base.update(over)
    return ProductDetail(**base)


class TestFormatMirrors:
    """Each case is Intl.NumberFormat("en-US") output, captured from Node."""

    def test_usd(self):
        assert seo.fmt_usd(1234.5) == "$1,234.50"
        assert seo.fmt_usd(0.5) == "$0.50"
        assert seo.fmt_usd(None) == "-"

    def test_local_symbols_and_codes(self):
        assert seo.fmt_local(1234.5, "EUR") == "€1,234.50"
        assert seo.fmt_local(1234.5, "CAD") == "CA$1,234.50"
        assert seo.fmt_local(1234.5, "AED") == "AED 1,234.50"
        assert seo.fmt_local(12.345, "CHF") == "CHF 12.35"

    def test_zero_decimal_currencies_drop_trailing_zeros(self):
        assert seo.fmt_local(1234, "ISK") == "ISK 1,234"
        assert seo.fmt_local(1234.5, "JPY") == "¥1,234.5"

    def test_size(self):
        assert seo.fmt_size(700) == "700ml"
        assert seo.fmt_size(1000) == "1L"
        assert seo.fmt_size(1500) == "1.5L"
        assert seo.fmt_size(None) is None

    def test_observed(self):
        assert seo.fmt_observed(NOW - timedelta(minutes=20), NOW) == "just now"
        assert seo.fmt_observed(NOW - timedelta(hours=5), NOW) == "5h ago"
        assert seo.fmt_observed(NOW - timedelta(hours=30), NOW) == "yesterday"
        assert seo.fmt_observed(NOW - timedelta(days=12), NOW) == "12 days ago"
        assert seo.is_stale(NOW - timedelta(days=4), NOW)
        assert not seo.is_stale(NOW - timedelta(days=2), NOW)

    def test_bottle_mark_hash_matches_javascript(self):
        """hashOf("Santa TeresaSanta Teresa 1796") | 0 in Node, then % 6."""
        assert seo._js_hash("Santa TeresaSanta Teresa 1796") == 1915516071  # Node: hashOf(...) for this string
        assert seo._initials("Santa Teresa", "x") == "ST"
        assert seo._initials(None, "Hennessy") == "HE"
        assert seo._initials(None, "???") == "DF"


class TestProductBody:
    def test_body_carries_name_prices_and_links(self):
        body = seo.product_body(detail(), medal_assets={"nyisc-double-gold.png"}, now=NOW)
        assert '<h1 class="product-title">Santa Teresa 1796 Ron Antiguo de Solera 70cl</h1>' in body
        assert "AED 140.00" in body and "$38.00" in body and "£52.99" in body
        assert 'href="/products?category=Rum"' in body
        assert 'href="/products?q=Santa%20Teresa"' in body
        assert 'href="/airports"' in body
        assert "700ml · 40% ABV · Venezuela · Barcode 7591174000018" in body
        assert 'src="/medals/nyisc-double-gold.png?v=2"' in body
        assert "cheapest at" in body and "Dubai (DXB)" in body
        assert "save $17.50 vs the dearest" in body

    def test_brand_links_to_its_page_only_when_the_api_handed_over_a_slug(self):
        """`ProductDetail.brand_slug` is the listed_brand page's slug when the brand has a
        page and None when it is too thin for one (about a quarter of visible
        products on a copy of the launch data). Linking the name blindly would have
        sent those to a 404; linking none of them left every brand a catalogue
        search after the brand pages shipped. The body mirrors ProductPage.tsx:
        page when the slug is there, search when it is not."""
        with_page = seo.product_body(detail(brand_slug="santa-teresa"), now=NOW)
        assert 'href="/brands/santa-teresa">Santa Teresa</a>' in with_page
        assert "/products?q=Santa%20Teresa" not in with_page
        without = seo.product_body(detail(brand_slug=None), now=NOW)
        assert 'href="/products?q=Santa%20Teresa">Santa Teresa</a>' in without
        assert "/brands/" not in without

    def test_cheapest_badge_skips_out_of_stock(self):
        d = detail(prices=[price(price_usd=38.0, in_stock=False), price(price_usd=55.5)])
        body = seo.product_body(d, now=NOW)
        rows = re.findall(r"<tr[^>]*>.*?</tr>", body)[1:]
        assert "Cheapest" not in rows[0] and "Cheapest" in rows[1]

    def test_missing_medal_art_falls_back_to_text_badge(self):
        body = seo.product_body(detail(), medal_assets=set(), now=NOW)
        assert "/medals/" not in body
        assert '<span class="badge badge--medal">Double Gold - NYISC - 2024</span>' in body

    def test_exclusive_badge_wears_the_excl_tone_like_the_spa(self):
        """C3 gave Badge an excl tone (gold on navy); the server body's copy of the
        badge kept the neutral class, so the served page painted a grey badge the
        SPA then recoloured."""
        body = seo.product_body(detail(is_exclusive=True), now=NOW)
        assert '<span class="badge badge--excl">Travel-retail exclusive</span>' in body
        assert "badge--neutral" not in body

    def test_no_image_renders_the_monogram(self):
        body = seo.product_body(detail(image_url=None, image_source=None), now=NOW)
        assert 'class="bottle-mark bottle-mark--lg"' in body
        assert ">ST<" in body

    def test_untrusted_strings_are_escaped(self):
        d = detail(name='Gin <b>"Special"</b> & Co', brand="A&B")
        body = seo.product_body(d, now=NOW)
        assert "<b>" not in body
        assert "Gin &lt;b&gt;" in body
        assert 'href="/products?q=A%26B"' in body

    def test_no_prices_says_so(self):
        body = seo.product_body(detail(prices=[], cheapest_usd=None, dearest_usd=None), now=NOW)
        assert "No prices collected for this product yet." in body
        assert "product-verdict" not in body

    def test_airport_row_links_by_the_path_the_api_sent(self):
        """The SPA and the server both take the row's link from the API now,
        so the two cannot drift when the URL shape changes."""
        body = seo.product_body(detail(), now=NOW)
        assert '<a href="/airports/heathrow-lhr-london">London Heathrow (LHR)</a>' in body
        d = detail(prices=[price(shop_path=None, shop_iata=None, shop_name="Heinemann online",
                                  is_catalogue_only=True)])
        body = seo.product_body(d, now=NOW)
        assert "Heinemann online" in body
        assert '<a href="/airports/' not in body


class TestHeaderMirror:
    """header_html mirrors SiteHeader.tsx closed: when C2 wrapped the Products item
    in a group with the browse-panel toggle, every server body kept the old
    single link until this mirror caught up. Verified token for token against
    headless Chrome's mounted DOM (2026-09-05); this pins the markup."""

    def test_each_department_carries_its_toggle_and_the_panel_is_the_closed_shell(self):
        """11 Sep: Airports, Drinks, Beauty and Articles replaced Products (and the
        quiet "Airports we price" link) at the head of the row, each a link plus
        the toggle for its own panel, as SiteHeader.tsx renders them closed;
        reviews, buying guides and news moved into Articles."""
        header = seo.header_html({"myAirports": True, "teasers": True}, active_path="/airports")
        assert (
            '<div class="site-nav__inner"><div class="site-nav__group">'
            '<a class="site-nav__link site-nav__link--active" href="/airports">Airports</a>'
            '<button type="button" class="site-nav__more" aria-expanded="false" aria-controls="site-mega-menu" '
            'aria-label="Show the Airports menu"></button></div>'
            '<div class="site-nav__group"><a class="site-nav__link" href="/products?family=liquor">Drinks</a>'
            '<button type="button" class="site-nav__more" aria-expanded="false" aria-controls="site-mega-menu" '
            'aria-label="Show the Drinks menu"></button></div>'
            '<div class="site-nav__group"><a class="site-nav__link" href="/products?family=beauty">Beauty</a>'
            '<button type="button" class="site-nav__more" aria-expanded="false" aria-controls="site-mega-menu" '
            'aria-label="Show the Beauty menu"></button></div>'
            '<div class="site-nav__group"><a class="site-nav__link" href="/articles">Articles</a>'
            '<button type="button" class="site-nav__more" aria-expanded="false" aria-controls="site-mega-menu" '
            'aria-label="Show the Articles menu"></button></div>'
            '<span class="site-nav__sep" aria-hidden="true"></span>'
            '<a class="site-nav__link" href="/feature/price-tracker">Price tracker</a>'
            '<a class="site-nav__link" href="/exclusives">Exclusives</a>'
            '<a class="site-nav__link" href="/awards">Awards</a>'
            '<span class="site-nav__spacer"></span>'
        ) in header
        # The panel is the empty hidden shell the SPA renders while closed, so
        # a crawler is shown nothing a visitor does not get; it sits after the
        # nav's inner row, before </nav>, so aria-controls resolves.
        assert header.count('<div id="site-mega-menu" class="mega" hidden role="region" aria-label="Browse by department"></div></nav>') == 1
        assert header.index('class="site-nav__inner"') < header.index('id="site-mega-menu"')
        assert header.count("site-nav__group") == 4
        row = header.split('<nav class="site-nav">')[1]
        for gone in ("Products</a>", "Airports we price", '"/feature/reviews"', '"/feature/guides"', '"/feature/news"'):
            assert gone not in row

    def test_other_items_stay_plain_links_and_the_active_class_follows_the_page(self):
        header = seo.header_html({"myAirports": True, "teasers": True}, active_path="/awards")
        assert '<a class="site-nav__link" href="/airports">Airports</a>' in header
        assert '<a class="site-nav__link site-nav__link--active" href="/awards">Awards</a>' in header
        assert header.count("site-nav__more") == 4  # the toggles are there whichever page is active
        article = seo.header_html({"myAirports": True, "teasers": True}, active_path="/articles")
        assert '<a class="site-nav__link site-nav__link--active" href="/articles">Articles</a>' in article

    def test_teasers_off_leaves_departments_and_the_built_pages(self):
        header = seo.header_html({"myAirports": True, "teasers": False})
        assert "/feature/" not in header.split('<nav class="site-nav">')[1]
        assert header.count("site-nav__group") == 4
        assert '<span class="site-nav__sep" aria-hidden="true"></span><a class="site-nav__link" href="/exclusives">' in header

    def test_top_row_is_search_airports_then_the_profile_circle(self):
        """11 Sep: My savings moved from the top row to the end of the nav, and the
        account became a profile circle beside the airports button, drawn signed
        out (AccountMenu.tsx swaps in the initials at the same 36px)."""
        header = seo.header_html({"myAirports": True, "teasers": True})
        assert 'placeholder="Search product, brand, or airport"' in header
        # SiteSearch.tsx's combobox, closed: the input names the suggestion list,
        # which is the same empty hidden shell the SPA renders until one types.
        assert 'aria-controls="site-search-results" aria-autocomplete="list" autocomplete="off" />' in header
        assert ('<div id="site-search-results" class="site-search__panel" role="listbox" '
                'aria-label="Search suggestions" hidden></div></form>') in header
        assert (
            '<div class="site-header__actions"><div class="my-airports">'
            '<button type="button" class="my-airports__btn" aria-expanded="false">'
            '<span class="my-airports__icon" aria-hidden="true">✈</span>'
            '<span class="my-airports__label">Set your airports</span></button></div>'
            '<div class="account-menu"><a href="/login" class="account-menu__trigger account-menu__trigger--guest" '
            'aria-label="Sign in" title="Sign in">'
        ) in header
        assert "site-header__cta" not in header
        assert header.index('class="site-header__actions"') < header.index('class="site-header__burger"')
        assert '<a class="site-nav__cta" href="/savings">My savings</a></div><div id="site-mega-menu"' in header
        off = seo.header_html({"myAirports": False, "teasers": True})
        assert "my-airports" not in off and "site-nav__cta" not in off
        assert '<div class="site-header__actions"><div class="account-menu">' in off

    def test_social_links_appear_only_once_a_profile_url_exists(self):
        """SocialLinks.tsx renders nothing until the shell hands it a URL; the
        mirror does the same from the same settings, so no dead icon ships."""
        flags = {"myAirports": True, "teasers": True}
        assert "social" not in seo.header_html(flags, social={})
        header = seo.header_html(flags, social={"youtube": "https://www.youtube.com/@dutyfreeprofessor"})
        assert (
            '<span class="site-nav__spacer"></span><div class="social social--nav">'
            '<a href="https://www.youtube.com/@dutyfreeprofessor" class="social__link" target="_blank" rel="noopener" '
            'aria-label="Duty Free Professor on YouTube" title="YouTube"><svg viewBox="0 0 24 24"'
        ) in header
        assert "Instagram" not in header
        assert header.index("social--nav") < header.index('id="site-mega-menu"')
        both = seo.social_links_html({"youtube": "https://y.example", "instagram": "https://i.example"}, "footer")
        assert both.index("Instagram") < both.index("YouTube")  # the component's fixed order
        assert '<span class="social__label">Instagram</span>' in both

    def test_organization_carries_same_as_only_for_profiles_that_exist(self):
        assert "sameAs" not in seo.organization_jsonld()
        org = seo.organization_jsonld(["https://www.instagram.com/dutyfreeprofessor"])
        assert org["sameAs"] == ["https://www.instagram.com/dutyfreeprofessor"]
        assert org["@id"] == "/#organization"

    def test_settings_keep_only_https_profiles_without_placeholders(self):
        from app.config import Settings

        s = Settings(social_instagram_url="https://www.instagram.com/dfp", social_youtube_url="REPLACE_WITH_YOUTUBE_URL")
        assert s.social_profiles == {"instagram": "https://www.instagram.com/dfp"}
        assert Settings(social_instagram_url="http://insecure.example").social_profiles == {}
        assert Settings().social_profiles == {}


class TestHeadForDetail:
    def test_head_fills_root_and_seeds_the_spa(self):
        head = seo.head_for_detail(detail(), medal_assets={"nyisc-double-gold.png"})
        page = head.apply(SHELL, "https://example.com")
        assert '<div id="root"></div>' not in page
        assert "Santa Teresa 1796" in page.split("<body>")[1]
        seed = re.search(r"window\.__DFP_PRODUCT__ = (.*?)</script>", page).group(1)
        assert json.loads(seed)["id"] == 1156
        assert json.loads(seed)["prices"][0]["price_usd"] == 38.0

    def test_seed_cannot_break_out_of_its_script_tag(self):
        head = seo.head_for_detail(detail(name="x</script><script>alert(1)"))
        page = head.apply(SHELL, "https://example.com")
        assert "</script><script>alert(1)" not in page

    def test_schema_and_title_still_come_from_the_same_detail(self):
        """House style reaches the served text too: the product title and the
        award line carried an em dash until 10 Sep, the one place a shopper or
        a search result would read one."""
        head = seo.head_for_detail(detail())
        assert head.title.startswith("Santa Teresa 1796 Ron Antiguo de Solera 70cl: duty-free prices at 3 shops")
        assert "\u2014" not in head.title and "\u2014" not in head.description
        product = head.jsonld[0]
        assert product["offers"]["offerCount"] == 3
        assert product["gtin13"] == "7591174000018"
        assert product["award"] == ["Double Gold, NYISC 2024"]
        assert "\u2014" not in seo.product_body(detail(), now=NOW)

    def test_shell_without_root_slot_is_left_alone(self):
        head = seo.head_for_detail(detail())
        page = head.apply("<html><head></head><body><main></main></body></html>", "")
        assert "<main></main>" in page


class TestOfferMarkup:
    """The price of record (structure review, 9 Sep; Mark: markup must be
    congruent with the page or Google tanks rankings). The shop's own price in
    its own currency is the only price in the markup."""

    def test_a_mixed_currency_product_states_no_range_and_each_offer_its_own_currency(self):
        """Product 1156, Santa Teresa 1796, the fixture: AED 140 at Dubai and
        GBP 52.99 at Heathrow. Until 9 Sep the combined offer said
        priceCurrency USD, lowPrice 38.00, highPrice 55.50 from our conversion
        while the offers beneath it were in dirhams and pounds: a range in a
        currency no shop quoted, and no such figure on the page as a price."""
        product = seo.head_for_detail(detail()).jsonld[0]
        offers = product["offers"]
        assert offers["@type"] == "AggregateOffer"
        for key in ("priceCurrency", "lowPrice", "highPrice"):
            assert key not in offers, key
        assert offers["offerCount"] == 3
        assert [(o["price"], o["priceCurrency"]) for o in offers["offers"]] == [("140.00", "AED"), ("52.99", "GBP")]
        assert "USD" not in json.dumps(product)

    def test_a_single_currency_product_states_the_shops_own_range(self):
        d = detail(prices=[price(price=52.99, price_usd=67.10), price(price=49.50, price_usd=62.70, shop_code="lgw", shop_name="London Gatwick", shop_iata="LGW", shop_path="/airports/lgw-london")], shop_count=2, cheapest_usd=62.70, dearest_usd=67.10)
        offers = seo.head_for_detail(d).jsonld[0]["offers"]
        assert (offers["priceCurrency"], offers["lowPrice"], offers["highPrice"]) == ("GBP", "49.50", "52.99")

    def test_the_page_leads_with_the_shop_price_and_labels_the_conversion(self):
        body = seo.price_table_html(detail().prices, NOW)
        assert "<th>Shop price</th>" in body
        assert 'Our conversion<span class="price-table__th-note">to US dollars, at the exchange rate we held on the date checked</span>' in body
        assert '<td class="price-table__shop">AED\xa0140.00</td>' in body
        assert "is our conversion, at the exchange rate we held on the date checked" in body

    def test_airport_and_brand_pages_carry_no_offer_markup(self):
        from test_seo_airport import airport
        from test_seo_brand import brand

        for head in (seo.head_for_airport(airport()), seo.head_for_brand(brand())):
            text = json.dumps(head.jsonld)
            assert '"Offer"' not in text and "AggregateOffer" not in text
            assert not re.search(r'"(price|lowPrice|highPrice|priceCurrency)"', text)
