"""The page routes over the built shell: 404s, HEAD, conditional GETs, cache headers.

Until 2026-09-04 an unknown product id or a mistyped path returned 200 with the
default head (so crawlers filed junk addresses as pages), HEAD returned 405, and
nothing carried an ETag or a Cache-Control that would stop a CDN from caching
an owner's edit. No database: `get_db` is overridden and the head builders are
stubbed with the same fixture the renderer tests use.
"""

from datetime import UTC, datetime

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from app import main
from app.db import get_db
from app.routers import catalog
from app.services import catalog_queries, editorial, feeds, publish, seo, urls
from test_crawl_surface import facts
from test_seo_airport import airport, summary
from test_seo_article import article
from test_seo_brand import brand, brand_summary
from test_seo_body import detail
from test_seo_line import line_detail

SHELL = (
    "<!doctype html><html><head><title>t</title>"
    '<meta name="description" content="d" /></head>'
    '<body><div id="root"></div></body></html>'
)
NEWEST = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)


@pytest.fixture
def client(tmp_path, monkeypatch):
    (tmp_path / "assets").mkdir()
    (tmp_path / "medals").mkdir()
    (tmp_path / "medals" / "nyisc-double-gold.png").write_bytes(b"png")
    (tmp_path / "index.html").write_text(SHELL)
    (tmp_path / "logo.png").write_bytes(b"png")

    fixture = detail()

    def fake_product_head(db, variant_id, medal_assets=()):
        return seo.head_for_detail(fixture, medal_assets) if variant_id == fixture.id else None

    def fake_airport_head(db, iata, medal_assets=(), flags=None, *, category=None, sort="featured",
                          page=1, limit=24, tab=None, multi_only=False, awarded_only=False, exclusives_only=False):
        features = urls.feature_flags(multi_only=multi_only, awarded_only=awarded_only, exclusives_only=exclusives_only)
        shelf = urls.airport_tab(tab, category, sort, page, features)
        if iata == "JFK":
            return seo.head_for_airport(
                airport(iata="JFK", path="/airports/jfk-new-york", name="New York JFK", city="New York",
                        category=category, sort=sort, offset=(page - 1) * limit),
                medal_assets, flags, shelf,
            )
        if iata != "LHR":
            return None
        return seo.head_for_airport(
            airport(category=category, sort=sort, offset=(page - 1) * limit, multi_only=multi_only,
                    awarded_only=awarded_only, exclusives_only=exclusives_only),
            medal_assets, flags, shelf,
        )

    def fake_brand_head(db, slug, medal_assets=(), flags=None, *, category=None, sort="featured", page=1, limit=24):
        # "jw" stands in for an alias row: it resolves to the brand, whose path differs.
        if slug not in ("johnnie-walker", "jw"):
            return None
        return seo.head_for_brand(
            brand(category=category, sort=sort, offset=(page - 1) * limit), medal_assets, flags
        )

    from test_airport_category import pair

    def fake_airport_category_head(db, iata, category_slug, medal_assets=(), flags=None, *, sort="featured",
                                   page=1, limit=24, multi_only=False, awarded_only=False, exclusives_only=False):
        # Whisky at Heathrow is over the bar; gin at Heathrow and anything at JFK are not.
        if iata != "LHR" or category_slug.lower() != "whisky":
            return None
        return seo.head_for_airport_category(
            pair(sort=sort, offset=(page - 1) * limit, multi_only=multi_only, awarded_only=awarded_only,
                 exclusives_only=exclusives_only),
            medal_assets, flags,
        )

    def fake_article_head(db, slug, flags=None):
        return seo.head_for_article(article(), flags) if slug == "how-to-read-a-duty-free-price" else None

    monkeypatch.setattr(seo, "product_head", fake_product_head)

    # The product line pages (Stream K5): one live line, one alias spelling, one merged-away id.
    from types import SimpleNamespace

    def fake_line_by_slug(db, slug):
        if slug == "rabanne-1-million":
            return SimpleNamespace(slug=slug), None
        return (None, "rabanne-1-million") if slug == "paco-rabanne-1-million" else (None, None)

    def fake_line_of_variant(db, variant_id):
        return {101: (101, "rabanne-1-million"), 42: (101, "rabanne-1-million"), 103: (103, "rabanne-1-million")}.get(variant_id)

    def fake_line_head(db, slug, medal_assets=(), flags=None, *, variant_id=None, airports=None):
        return seo.head_for_line(line_detail(variant_id, airports), medal_assets, flags) if slug == "rabanne-1-million" else None

    # What a person hid (Stream K6): a brand, a product line and an airport's place.
    hidden = {("brand", "wrong-brand"): "/products?brand=Wrong%20Brand", ("product_line", "wrong-brand-line"): "/brands/johnnie-walker",
              ("place", "XXH"): "/airports"}
    monkeypatch.setattr(publish, "hidden_target", lambda db, kind, ref: hidden.get((kind, ref)))
    monkeypatch.setattr(catalog_queries, "line_by_slug", fake_line_by_slug)
    monkeypatch.setattr(catalog_queries, "line_of_variant", fake_line_of_variant)
    monkeypatch.setattr(seo, "line_head", fake_line_head)
    monkeypatch.setattr(seo, "airport_head", fake_airport_head)
    monkeypatch.setattr(seo, "airport_category_head", fake_airport_category_head)
    monkeypatch.setattr(seo, "brand_head", fake_brand_head)
    monkeypatch.setattr(seo, "article_head", fake_article_head)
    monkeypatch.setattr(catalog_queries, "dataset_facts", lambda db: facts())
    monkeypatch.setattr(feeds, "feed_items", lambda db: [feeds.product_item(summary(), NEWEST)])
    monkeypatch.setattr(main.settings, "indexnow_key", "abc123key")
    monkeypatch.setattr(seo, "sitemap_entries", lambda db, base, include_my_airports=True, line_pages=True: (
        f"<urlset><url><loc>{base}/</loc></url></urlset>", NEWEST))
    monkeypatch.setattr(main.settings, "public_base_url", "https://s.example")
    monkeypatch.setattr(main.settings, "site_access", "public")  # the crawl surface as the public sees it
    monkeypatch.setattr(main.settings, "site_access", "public")  # the crawl surface as the public sees it

    app = FastAPI()
    app.middleware("http")(main.cache_headers)
    app.dependency_overrides[get_db] = lambda: iter([None])
    main.mount_site(app, tmp_path)
    return TestClient(app)


@pytest.fixture
def variant_pages(monkeypatch):
    """LINE_PAGES=false: the variant pages and their addresses, restored until Cannes."""
    monkeypatch.setattr(main.settings, "line_pages", False)


class TestHiddenPages:
    """A page a person hid answers 302 to the nearest right page, never 404 (Stream K6; plan
    W18): hidden means wrong and may be put right, so the redirect is not permanent."""

    def test_a_hidden_brand_line_and_airport_answer_302(self, client):
        for address, target in (("/brands/wrong-brand", "/products?brand=Wrong%20Brand"), ("/brands/Wrong-Brand?sort=price", "/products?brand=Wrong%20Brand"),
                                ("/products/wrong-brand-line?variant=3", "/brands/johnnie-walker"),
                                ("/airports/xxh", "/airports"), ("/airports/xxh/whisky", "/airports")):
            r = client.get(address, follow_redirects=False)
            assert (r.status_code, r.headers.get("location")) == (302, target), address


class TestNotFound:
    def test_unknown_product_id_is_404_with_noindex(self, client):
        r = client.get("/products/some-bottle-999")
        assert r.status_code == 404
        assert 'content="noindex, nofollow"' in r.text
        assert '<div id="root"></div>' in r.text  # the shell, so the SPA draws its own not-found page

    def test_slug_without_an_id_is_404(self, client):
        assert client.get("/products/just-words").status_code == 404

    def test_the_variant_shapes_404_the_same_behind_the_flag(self, client, variant_pages):
        assert client.get("/products/some-bottle-999").status_code == 404
        assert client.get("/products/just-words").status_code == 404

    def test_unknown_route_is_404(self, client):
        assert client.get("/nope").status_code == 404
        assert client.get("/feature/").status_code == 404

    def test_known_routes_are_200(self, client):
        for path in ("/", "/products", "/airports", "/trip", "/feature/alerts", "/settings/"):
            assert client.get(path).status_code == 200, path

    def test_missing_file_is_a_plain_404(self, client):
        assert client.get("/missing.xml").status_code == 404

    def test_real_file_is_served(self, client):
        assert client.get("/logo.png").status_code == 200


class TestProductLinePage:
    """Plan W1: `/products/<line>[?variant&airports]`, one canonical, every old address a 301."""

    def test_every_view_answers_200_noindex_follow_with_the_bare_canonical(self, client):
        for path in ("/products/rabanne-1-million", "/products/rabanne-1-million?variant=101",
                     "/products/rabanne-1-million?variant=101&airports=LHR,CDG"):
            r = client.get(path, follow_redirects=False)
            assert r.status_code == 200, path
            assert '<meta name="robots" content="noindex, follow" />' in r.text
            assert '<link rel="canonical" href="https://s.example/products/rabanne-1-million" />' in r.text

    def test_the_query_parameters_choose_the_view_and_a_mangled_one_is_ignored(self, client):
        r = client.get("/products/rabanne-1-million?variant=101&airports=lhr,xx,CDG")
        assert '"case": "chosen_airports"' in r.text and '"airports": ["LHR", "CDG"]' in r.text
        r = client.get("/products/rabanne-1-million?variant=abc&airports=")
        assert r.status_code == 200 and '"case": "ask_variant"' in r.text

    def test_an_old_variant_address_301s_to_its_line_with_the_variant_chosen(self, client):
        for path in ("/products/1-million-eau-de-toilette-100-ml-101", "/products/101"):
            r = client.get(path, follow_redirects=False)
            assert r.status_code == 301 and r.headers["location"] == "/products/rabanne-1-million?variant=101", path

    def test_a_merged_away_id_lands_on_its_survivor_and_the_airports_ride_along(self, client):
        r = client.get("/products/old-bottle-42?airports=SIN", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/products/rabanne-1-million?variant=101&airports=SIN"

    def test_an_alias_spelling_301s_to_the_line_with_its_query(self, client):
        r = client.get("/products/paco-rabanne-1-million?variant=103", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/products/rabanne-1-million?variant=103"


class TestProductPage:
    @pytest.fixture(autouse=True)
    def _flag(self, variant_pages):
        return None

    def test_stale_slug_redirects_to_canonical(self, client):
        r = client.get("/products/1156", follow_redirects=False)
        assert r.status_code == 301
        assert r.headers["location"] == "/products/santa-teresa-1796-ron-antiguo-de-solera-70cl-1156"

    def test_body_and_absolute_canonical(self, client):
        r = client.get("/products/santa-teresa-1796-ron-antiguo-de-solera-70cl-1156")
        assert r.status_code == 200
        assert "<h1" in r.text and "$38.00" in r.text
        assert 'href="https://s.example/products/santa-teresa-1796-ron-antiguo-de-solera-70cl-1156"' in r.text
        assert '"@id": "https://s.example/products/santa-teresa-1796-ron-antiguo-de-solera-70cl-1156#product"' in r.text
        assert r.headers["last-modified"] == "Sat, 05 Sep 2026 07:00:00 GMT"


class TestAirportPage:
    """The first hub page type. Same contract as a product page: a real 404 for
    an airport we do not show, one canonical path per airport, the body in the
    HTML, filters and paging read leniently."""

    def test_body_and_absolute_canonical(self, client):
        r = client.get("/airports/heathrow-lhr-london")
        assert r.status_code == 200
        assert "<h1" in r.text and "Duty free at London Heathrow" in r.text
        assert 'href="https://s.example/airports/heathrow-lhr-london"' in r.text
        assert '"@id": "https://s.example/airports/heathrow-lhr-london#page"' in r.text
        assert "window.__DFP_AIRPORT__ = " in r.text
        assert r.headers["last-modified"] == "Tue, 25 Aug 2026 23:06:00 GMT"
        assert r.headers["cache-control"] == "no-store"

    def test_bare_code_and_stale_words_redirect_keeping_the_query(self, client):
        """Every airport page was linked as `/airports/lhr-london` from 5 to
        9 Sep, when the review put the airport's name first; nothing was
        indexed, so the whole cost of the change is this redirect, and it
        must keep the filters."""
        for path in (
            "/airports/lhr", "/airports/LHR-Heathrow", "/airports/lhr-london",
            "/airports/lhr-london-airport", "/airports/heathrow-lhr-terminal-5",
            "/airports/london-lhr",
        ):
            r = client.get(path, follow_redirects=False)
            assert r.status_code == 301, path
            assert r.headers["location"] == "/airports/heathrow-lhr-london", path
        r = client.get("/airports/lhr-london?category=Whisky&page=2", follow_redirects=False)
        assert r.headers["location"] == "/airports/heathrow-lhr-london?category=Whisky&page=2"

    def test_jfk_is_the_one_code_first_airport(self, client):
        """Mark, 10 Sep: "all I can think of is JFK"; the exception is data
        in the table, not a rule in code."""
        r = client.get("/airports/jfk-new-york")
        assert r.status_code == 200
        assert 'href="https://s.example/airports/jfk-new-york"' in r.text
        for path in ("/airports/jfk", "/airports/jfk-new-york-city", "/airports/JFK-New-York"):
            r = client.get(path, follow_redirects=False)
            assert r.status_code == 301, path
            assert r.headers["location"] == "/airports/jfk-new-york", path

    def test_unknown_or_hidden_airport_is_a_404_with_noindex(self, client):
        for path in ("/airports/dxb-dubai", "/airports/dubai-dxb", "/airports/nope", "/airports/heathrow-lhr-london/"):
            r = client.get(path)
            assert r.status_code == 404, path
            assert 'content="noindex, nofollow"' in r.text

    def test_filters_and_paging_are_read_leniently(self, client):
        r = client.get("/airports/heathrow-lhr-london?category=Whisky&sort=price&page=2")
        assert r.status_code == 200
        # A filter can only mean the full list, so it selects that shelf.
        assert "Whisky priced at London Heathrow" in r.text
        assert 'class="shelf-tab shelf-tab--active" aria-current="page">All products' in r.text
        assert '<span class="pager__status">25-48 of 266</span>' in r.text
        # The filtered view canonicalises to the clean page; garbage paging is page one.
        assert 'href="https://s.example/airports/heathrow-lhr-london"' in r.text
        assert '<span class="pager__status">1-24 of 266</span>' in client.get("/airports/heathrow-lhr-london?page=abc&sort=bogus").text

    def test_a_shopping_feature_flag_selects_the_full_list_and_keeps_the_clean_canonical(self, client):
        """G5: `?awarded_only=true` is a view for people; the crawler is told the clean page."""
        r = client.get("/airports/heathrow-lhr-london?awarded_only=true")
        assert r.status_code == 200
        assert 'class="shelf-tab shelf-tab--active" aria-current="page">All products' in r.text
        assert 'class="filter-chip filter-chip--gold filter-chip--active" href="/airports/heathrow-lhr-london?tab=all#products" aria-pressed="true">Award winners' in r.text
        assert 'href="https://s.example/airports/heathrow-lhr-london"' in r.text
        # A mangled flag is off, and the page opens on the savings as before.
        r = client.get("/airports/heathrow-lhr-london?awarded_only=bogus")
        assert 'aria-current="page">Best value here' in r.text
        # The redirect from a stale slug keeps the flag.
        r = client.get("/airports/lhr?multi_only=true", follow_redirects=False)
        assert r.headers["location"] == "/airports/heathrow-lhr-london?multi_only=true"

    def test_head_and_conditional_get(self, client):
        r = client.head("/airports/heathrow-lhr-london")
        assert r.status_code == 200 and r.content == b"" and r.headers["etag"].startswith('W/"')
        first = client.get("/airports/heathrow-lhr-london")
        assert client.get("/airports/heathrow-lhr-london", headers={"If-None-Match": first.headers["etag"]}).status_code == 304

    def test_api_404s_a_code_that_is_not_an_airport(self, monkeypatch):
        monkeypatch.setattr(catalog_queries, "airport_detail", lambda db, iata, **kw: None)
        api = FastAPI()
        api.include_router(catalog.router)
        api.dependency_overrides[get_db] = lambda: iter([None])
        client = TestClient(api)
        assert client.get("/api/airports/LHR").status_code == 404
        assert client.get("/api/airports/london").status_code == 404
        assert client.get("/api/airports/LHR?sort=bogus").status_code == 422


class TestAirportCategoryPage:
    """Category at airport (G7): a page only over the coverage bar, the airport part
    canonicalising like the airport page, the category word exact, the body in the HTML."""

    def test_body_canonical_seed_and_head(self, client):
        r = client.get("/airports/heathrow-lhr-london/whisky")
        assert r.status_code == 200
        assert "<h1" in r.text and "Whisky at London Heathrow" in r.text
        assert 'href="https://s.example/airports/heathrow-lhr-london/whisky"' in r.text
        assert '"@id": "https://s.example/airports/heathrow-lhr-london/whisky#page"' in r.text
        assert "window.__DFP_AIRPORT_CATEGORY__ = " in r.text
        assert r.headers["cache-control"] == "no-store"
        assert client.head("/airports/heathrow-lhr-london/whisky").status_code == 200
        assert client.get("/airports/heathrow-lhr-london/whisky", headers={"If-None-Match": r.headers["etag"]}).status_code == 304

    def test_a_pair_under_the_bar_or_an_unknown_word_is_a_real_404(self, client):
        for path in ("/airports/heathrow-lhr-london/gin", "/airports/jfk-new-york/whisky",
                     "/airports/heathrow-lhr-london/whiskey", "/airports/heathrow-lhr-london/categories",
                     "/airports/nope/whisky", "/airports/heathrow-lhr-london/whisky/"):
            r = client.get(path)
            assert r.status_code == 404, path
            assert 'content="noindex, nofollow"' in r.text

    def test_a_stale_airport_slug_or_cased_word_redirects_keeping_the_query(self, client):
        r = client.get("/airports/lhr-london/whisky?sort=price&page=2", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/airports/heathrow-lhr-london/whisky?sort=price&page=2"
        r = client.get("/airports/heathrow-lhr-london/Whisky", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/airports/heathrow-lhr-london/whisky"

    def test_filters_paging_and_the_clean_canonical(self, client):
        r = client.get("/airports/heathrow-lhr-london/whisky?awarded_only=true&sort=price&page=2")
        assert r.status_code == 200
        assert 'class="filter-chip filter-chip--gold filter-chip--active" href="/airports/heathrow-lhr-london/whisky?sort=price#products" aria-pressed="true">Award winners' in r.text
        assert '<span class="pager__status">25-48 of 67</span>' in r.text
        assert 'href="https://s.example/airports/heathrow-lhr-london/whisky"' in r.text

    def test_api_404s_a_pair_with_no_page(self, monkeypatch):
        from app.routers import airports as airports_router

        monkeypatch.setattr(catalog_queries, "airport_category_detail", lambda db, iata, category, **kw: None)
        api = FastAPI()
        api.include_router(airports_router.router)
        api.dependency_overrides[get_db] = lambda: iter([None])
        c = TestClient(api)
        assert c.get("/api/airports/LHR/categories/whisky").status_code == 404
        assert c.get("/api/airports/LHR/categories/whiskey").status_code == 404
        assert c.get("/api/airports/LHR/categories/whisky?sort=bogus").status_code == 422


class TestBrandPage:
    def test_body_canonical_and_seed(self, client):
        r = client.get("/brands/johnnie-walker")
        assert r.status_code == 200
        assert "Johnnie Walker in duty free" in r.text
        assert 'href="https://s.example/brands/johnnie-walker"' in r.text
        assert '"@id": "https://s.example/brands/johnnie-walker#brand"' in r.text
        assert "window.__DFP_BRAND__ = " in r.text
        assert r.headers["last-modified"] == "Tue, 25 Aug 2026 23:51:00 GMT"
        assert client.head("/brands/johnnie-walker").status_code == 200

    def test_alias_and_case_redirect_to_the_house_keeping_the_query(self, client):
        r = client.get("/brands/jw?sort=price", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/brands/johnnie-walker?sort=price"
        r = client.get("/brands/Johnnie-Walker", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/brands/johnnie-walker"

    def test_unknown_or_thin_brand_is_a_404(self, client):
        r = client.get("/brands/campbeltown")
        assert r.status_code == 404 and 'content="noindex, nofollow"' in r.text
        assert client.get("/brands/johnnie-walker/").status_code == 404

    def test_filters_and_paging(self, client):
        r = client.get("/brands/johnnie-walker?category=Whisky&page=2&sort=bogus")
        assert r.status_code == 200
        assert "Johnnie Walker whisky" in r.text
        assert '<span class="pager__status">25-48 of 21</span>' not in r.text  # 21 fit on one page: no pager
        assert 'href="https://s.example/brands/johnnie-walker"' in r.text

    def test_api_404s_an_unknown_brand(self, monkeypatch):
        monkeypatch.setattr(catalog_queries, "brand_detail", lambda db, slug, **kw: None)
        api = FastAPI()
        api.include_router(catalog.router)
        api.dependency_overrides[get_db] = lambda: iter([None])
        client = TestClient(api)
        assert client.get("/api/brands/nope").status_code == 404
        assert client.get("/api/brands/nope?sort=bogus").status_code == 422


class TestArticlePage:
    """Stream D's pages behind B's routes: the index is a static head over the SPA,
    the piece has a route of its own, and a draft or unknown slug is a real 404."""

    def test_shell_carries_the_social_profiles_beside_the_flags(self, client):
        r = client.get("/articles")
        assert "window.__DFP_SOCIAL__ = {}" in r.text  # nothing configured in tests: the SPA draws no icon

    def test_index_is_a_known_route_with_its_head(self, client):
        r = client.get("/articles")
        assert r.status_code == 200
        assert "<title>Articles | Duty Free Professor</title>" in r.text
        assert 'href="https://s.example/articles"' in r.text
        assert '<div id="root"></div>' in r.text  # the list itself is the SPA's

    def test_piece_body_canonical_seed_and_head(self, client):
        r = client.get("/articles/how-to-read-a-duty-free-price")
        assert r.status_code == 200
        assert '<h1 class="article-page__title">How to read a duty-free price</h1>' in r.text
        assert "<em>destination</em>" in r.text
        assert 'href="https://s.example/articles/how-to-read-a-duty-free-price"' in r.text
        assert "window.__DFP_ARTICLE__ = " in r.text
        assert '"@type": "Article"' in r.text
        assert r.headers["last-modified"] == "Thu, 03 Sep 2026 16:00:00 GMT"
        assert r.headers["cache-control"] == "no-store"
        assert client.head("/articles/how-to-read-a-duty-free-price").status_code == 200
        assert client.get("/articles/how-to-read-a-duty-free-price", headers={"If-None-Match": r.headers["etag"]}).status_code == 304

    def test_draft_or_unknown_slug_is_a_404(self, client):
        r = client.get("/articles/not-published-yet")
        assert r.status_code == 404 and 'content="noindex, nofollow"' in r.text
        assert client.get("/articles/how-to-read-a-duty-free-price/").status_code == 404
        assert client.get("/articles/" + "x" * 161).status_code == 404


class TestCrawlSurface:
    """robots.txt by bot, the feed, llms.txt, the IndexNow key file and the data
    page, each with HEAD and conditional GET like every other machine-read URL."""

    def test_robots_carries_the_per_bot_policy_and_signals(self, client):
        r = client.get("/robots.txt")
        assert r.status_code == 200 and r.headers["content-type"].startswith("text/plain")
        assert "Content-Signal: search=yes, ai-input=yes, ai-train=no" in r.text
        assert "User-agent: GPTBot\nDisallow: /" in r.text
        assert "Disallow: /api/" in r.text
        assert "Sitemap: https://s.example/sitemap.xml" in r.text

    def test_feed_is_rss_with_absolute_links_and_the_newest_item_date(self, client):
        r = client.get("/feed.xml")
        assert r.status_code == 200 and r.headers["content-type"] == "application/rss+xml"
        assert "<link>https://s.example/products/santa-teresa-1796-solera-rum-1l-1156</link>" in r.text
        assert r.headers["last-modified"] == "Tue, 01 Sep 2026 12:00:00 GMT"
        assert client.head("/feed.xml").status_code == 200
        assert client.get("/feed.xml", headers={"If-None-Match": r.headers["etag"]}).status_code == 304

    def test_llms_txt_is_markdown_from_the_facts(self, client):
        r = client.get("/llms.txt")
        assert r.status_code == 200 and r.headers["content-type"].startswith("text/markdown")
        assert r.text.startswith("# Duty Free Professor")
        assert "(https://s.example/airports/heathrow-lhr-london): 266 products" in r.text
        assert r.headers["last-modified"] == "Tue, 25 Aug 2026 23:51:00 GMT"

    def test_indexnow_key_file_is_served_only_for_the_configured_key(self, client):
        r = client.get("/abc123key.txt")
        assert r.status_code == 200 and r.text == "abc123key"
        assert client.get("/otherkey.txt").status_code == 404

    def test_data_page_has_the_body_and_the_dataset_markup(self, client):
        r = client.get("/data")
        assert r.status_code == 200
        assert "<h1" in r.text and "Airport duty-free price observations" in r.text
        assert '"@type": "Dataset"' in r.text and '"@id": "https://s.example/data#dataset"' in r.text
        assert "<dt>Price observations</dt><dd>2,315</dd>" in r.text
        assert 'href="https://s.example/data"' in r.text
        assert "window.__DFP_DATASET__ = " in r.text
        assert r.headers["cache-control"] == "no-store"
        assert client.head("/data").status_code == 200

    def test_every_head_links_the_feed(self, client):
        assert 'type="application/rss+xml" title="Duty Free Professor: new in duty free" href="https://s.example/feed.xml"' in client.get("/").text


class TestSitemap:
    """Only what a person approved for indexing is listed (Stream K6; plan W18). Before, every
    airport, every brand over the floor and every VARIANT address was listed, and with the line
    pages on each of those product URLs was a 301 to a noindex page."""

    def _catalogue(self, monkeypatch, airports=(), brands=(), lines=()):
        lhr = airport()
        monkeypatch.setattr(catalog_queries, "list_airports", lambda db: [lhr])
        monkeypatch.setattr(catalog_queries, "list_brands", lambda db, *floor: [brand_summary()])
        monkeypatch.setattr(seo, "_product_sitemap_rows", lambda db: [(1156, "Santa Teresa 1796", NEWEST)])
        monkeypatch.setattr(editorial, "sitemap_rows", lambda db: [])  # articles: test_seo_article.py
        # The category-at-airport pages over the bar (G7), dated like their airport.
        monkeypatch.setattr(catalog_queries, "category_page_rows", lambda db: [("/airports/heathrow-lhr-london/whisky", lhr.last_collected_at)])
        monkeypatch.setattr(seo, "indexed_airport_codes", lambda db: set(airports))
        monkeypatch.setattr(seo, "indexed_brand_slugs", lambda db: set(brands))
        monkeypatch.setattr(seo, "indexed_line_rows", lambda db, since=None: list(lines))

    def test_nothing_generated_is_listed_until_a_person_approves_it(self, monkeypatch):
        self._catalogue(monkeypatch)
        xml, newest = seo.sitemap_entries(None, "https://s.example", include_my_airports=False)
        assert "/airports/heathrow" not in xml and "/brands/" not in xml and "/products/" not in xml
        assert "<loc>https://s.example/airports</loc>" in xml and "<loc>https://s.example/data</loc>" in xml
        assert "/savings" not in xml and newest is None

    def test_approved_pages_carry_their_newest_observation(self, monkeypatch):
        self._catalogue(monkeypatch, airports={"LHR"}, brands={"johnnie-walker"}, lines=[("/products/santa-teresa-1796", NEWEST)])
        xml, newest = seo.sitemap_entries(None, "https://s.example", include_my_airports=False)
        assert "<url><loc>https://s.example/airports/heathrow-lhr-london</loc><lastmod>2026-08-25</lastmod></url>" in xml
        assert "<url><loc>https://s.example/airports/heathrow-lhr-london/whisky</loc><lastmod>2026-08-25</lastmod></url>" in xml, "a pairing page follows its airport"
        assert "<url><loc>https://s.example/brands/johnnie-walker</loc><lastmod>2026-08-25</lastmod></url>" in xml
        assert "<url><loc>https://s.example/products/santa-teresa-1796</loc><lastmod>2026-09-01</lastmod></url>" in xml
        assert "santa-teresa-1796-1156" not in xml, "a variant address is a redirect and is never listed"
        assert newest == NEWEST

    def test_an_approved_airport_does_not_list_another_airports_pairing_pages(self, monkeypatch):
        self._catalogue(monkeypatch, airports={"CDG"})
        xml, _ = seo.sitemap_entries(None, "https://s.example")
        assert "/airports/heathrow" not in xml

    def test_with_the_line_pages_off_the_variant_pages_are_listed_again(self, monkeypatch):
        self._catalogue(monkeypatch)
        xml, _ = seo.sitemap_entries(None, "https://s.example", line_pages=False)
        assert "<loc>https://s.example/products/santa-teresa-1796-1156</loc>" in xml


class TestHeadAndConditional:
    def test_head_returns_headers_only(self, client):
        r = client.head("/products/rabanne-1-million")
        assert r.status_code == 200
        assert r.headers["etag"].startswith('W/"')
        assert r.content == b""
        assert client.head("/robots.txt").status_code == 200
        assert client.head("/sitemap.xml").status_code == 200
        assert client.head("/").status_code == 200

    def test_if_none_match_gets_304(self, client):
        first = client.get("/")
        r = client.get("/", headers={"If-None-Match": first.headers["etag"]})
        assert r.status_code == 304
        assert r.content == b""
        assert r.headers["etag"] == first.headers["etag"]

    def test_if_modified_since_gets_304_only_when_current(self, client):
        path = "/sitemap.xml"
        first = client.get(path)
        assert first.headers["last-modified"] == "Tue, 01 Sep 2026 12:00:00 GMT"
        assert client.get(path, headers={"If-Modified-Since": first.headers["last-modified"]}).status_code == 304
        assert client.get(path, headers={"If-Modified-Since": "Mon, 31 Aug 2026 12:00:00 GMT"}).status_code == 200

    def test_a_404_is_never_a_304(self, client):
        first = client.get("/nope")
        assert client.get("/nope", headers={"If-None-Match": first.headers["etag"]}).status_code == 404


class TestCacheControl:
    def test_html_is_no_store(self, client):
        assert client.get("/").headers["cache-control"] == "no-store"
        assert client.get("/products/rabanne-1-million?variant=101").headers["cache-control"] == "no-store"

    def test_json_is_no_store(self, client):
        client.app.get("/api/ping")(lambda: {"ok": True})
        assert client.get("/api/ping").headers["cache-control"] == "no-store"

    def test_assets_are_immutable(self, client):
        assert client.get("/logo.png").headers.get("cache-control") is None
        client.app.get("/assets/x.js")(lambda: "x")
        assert "immutable" in client.get("/assets/x.js").headers["cache-control"]

    def test_robots_names_the_configured_sitemap(self, client):
        r = client.get("/robots.txt")
        assert "Sitemap: https://s.example/sitemap.xml" in r.text
        assert "etag" in r.headers and "last-modified" in r.headers


class TestMergedProducts:
    """Every merged product 301s to its survivor (Decision 6). Until migration #3
    the resolver is the identity; these pin the consumers so A only has to fill
    in the lookup."""

    def test_page_route_redirects_a_merged_id_to_the_survivor(self, client, monkeypatch, variant_pages):
        monkeypatch.setattr(catalog_queries, "resolve_variant_id", lambda db, pid: 1156 if pid == 42 else pid)
        r = client.get("/products/old-bottle-42", follow_redirects=False)
        assert r.status_code == 301
        assert r.headers["location"] == "/products/santa-teresa-1796-ron-antiguo-de-solera-70cl-1156"

    def test_api_redirects_a_merged_id_and_keeps_the_query(self, monkeypatch, variant_pages):
        monkeypatch.setattr(catalog_queries, "resolve_variant_id", lambda db, pid: 1156 if pid == 42 else pid)
        api = FastAPI()
        api.include_router(catalog.router)
        api.dependency_overrides[get_db] = lambda: iter([None])
        client = TestClient(api)
        r = client.get("/api/products/42", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/api/products/1156"
        r = client.get("/api/products/42/similar?limit=4&at=LHR", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/api/products/1156/similar?limit=4&at=LHR"

    def test_the_line_api_answers_the_line_and_301s_an_old_id_and_an_alias(self, monkeypatch):
        monkeypatch.setattr(catalog_queries, "line_by_slug", lambda db, slug: (
            (type("L", (), {"slug": slug})(), None) if slug == "rabanne-1-million" else
            ((None, "rabanne-1-million") if slug == "paco-rabanne-1-million" else (None, None))))
        monkeypatch.setattr(catalog_queries, "line_of_variant", lambda db, vid: (101, "rabanne-1-million") if vid in (42, 101) else None)
        monkeypatch.setattr(catalog_queries, "get_product_line", lambda db, slug, variant_id, airports: line_detail(variant_id, airports))
        api = FastAPI()
        api.include_router(catalog.router)
        api.dependency_overrides[get_db] = lambda: iter([None])
        client = TestClient(api)
        r = client.get("/api/products/rabanne-1-million?variant=103&airports=LHR")
        assert r.status_code == 200 and r.json()["price_block"]["case"] == "none_at_chosen"
        r = client.get("/api/products/42?airports=SIN", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/api/products/rabanne-1-million?variant=101&airports=SIN"
        r = client.get("/api/products/paco-rabanne-1-million", follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == "/api/products/rabanne-1-million"
        assert client.get("/api/products/nothing-here").status_code == 404

    def test_identity_resolver_is_a_pass_through_until_migration_3(self):
        assert catalog_queries.resolve_variant_id(None, 7) == 7


class TestMovedClientPages:
    """T8: six client surfaces became three pages, and no address Adam or Mark was given may
    break. Each old address answers a 301 whose Location carries the tab in the query and no
    fragment, so a deep link `/todo#t-todo-9#c-88` lands on `/discuss?tab=todo#t-todo-9#c-88`
    (a browser inherits the request's fragment when the Shop has none). The routes sit
    ahead of the shell on the real app, behind client.view like the pages they replace, so an
    anonymous caller is sent to sign in first and the redirect is never a way to learn a page
    exists. `/issues` is a page of its own now and `/plan` stays."""

    @pytest.fixture
    def signed(self, tmp_path, monkeypatch):
        from app.models import AccountLevel
        from app.services import access, accounts
        from tests import _accounts as T

        T.fresh(monkeypatch)
        monkeypatch.setattr(main.settings, "site_access", "members")
        T.person("rian")
        from tests.kit import _env
        with _env.TestSessionLocal() as db:
            db.add(AccountLevel(name="admin", permissions=list(accounts.SEED_LEVELS["admin"]["permissions"]), assignable=[]))
            db.commit()
        T.person("adam", level="admin")
        (tmp_path / "assets").mkdir()
        (tmp_path / "index.html").write_text(SHELL)
        before = len(main.app.routes)
        main.mount_site(main.app, tmp_path)
        try:
            yield T
        finally:
            del main.app.routes[before:]
            access.ROOT_FILES = frozenset()
            if hasattr(main.app.state, "not_found"):
                del main.app.state.not_found

    @pytest.mark.parametrize("old, new", [
        ("/todo", "/discuss?tab=todo"),
        ("/structure", "/discuss?tab=structure"),
        ("/quote", "/discuss?tab=quote"),
    ])
    def test_each_old_address_301s_to_its_tab_with_no_fragment_in_the_location(self, signed, old, new):
        c = signed.client()
        signed.as_user(c, "adam")
        r = c.get(old, follow_redirects=False)
        assert r.status_code == 301 and r.headers["location"] == new, (old, r.status_code)
        assert "#" not in r.headers["location"] and r.headers["cache-control"] == "no-store"
        assert c.head(old, follow_redirects=False).status_code == 301
        # The destination is a page, and the redirect target needs no further hop.
        assert c.get(new, follow_redirects=False).status_code == 200

    def test_an_anonymous_caller_is_sent_to_sign_in_not_redirected(self, signed):
        c = signed.client()
        for old in ("/todo", "/structure", "/quote"):
            r = c.get(old, follow_redirects=False)
            assert r.status_code == 302 and r.headers["location"] == f"/login?next={old}", old

    def test_issues_is_a_page_and_plan_stays(self, signed):
        c = signed.client()
        signed.as_user(c, "rian")
        assert c.get("/issues", follow_redirects=False).status_code == 200
        assert c.get("/plan", follow_redirects=False).status_code == 200
        assert c.get("/discuss", follow_redirects=False).status_code == 200
        signed.as_user(c, "adam")
        assert c.get("/issues", follow_redirects=False).status_code == 404  # a client is not told it exists
