"""Article pages (Stream D's text, served by B's routes).

`/articles` is a static head over the SPA and `/articles/<slug>` a page of its own:
head, Article + BreadcrumbList markup, the ArticlePage markup class for class, the
seed the SPA reads instead of fetching, the feed and the sitemap. The fixtures
stand in for the database; test_site_routes.py mounts the real routes over them.
"""

import json
import re
from datetime import UTC, datetime

from app.models.editorial import ArticleOut, ArticlePage, ArticleSummary
from app.services import catalog_queries, editorial, feeds, seo
from test_seo_airport import SHELL, summary

PUBLISHED = datetime(2026, 9, 1, 9, 30, tzinfo=UTC)
EDITED = datetime(2026, 9, 3, 16, 0, tzinfo=UTC)


def article(**over) -> ArticleOut:
    base = dict(
        slug="how-to-read-a-duty-free-price",
        path="/articles/how-to-read-a-duty-free-price",
        title="How to read a duty-free price",
        standfirst="A price is an observation with a date, never a quote.",
        excerpt="A price is an observation with a date, never a quote.",
        kind="article",
        category="Duty free basics",
        airport_code=None,
        hero_image=None,
        published_at=PUBLISHED,
        updated_at=EDITED,
        body_html="<p>Airport prices vary by <em>destination</em> and tier.</p>",
        description="A price is an observation with a date, never a quote.",
    )
    base.update(over)
    return ArticleOut(**base)


def card(**over) -> ArticleSummary:
    """One piece as a card sees it: the summary the centre's grid and its ItemList read."""
    fields = set(ArticleSummary.model_fields)
    base = {k: v for k, v in article().model_dump().items() if k in fields}
    base.update(over)
    return ArticleSummary(**base)


def listing(items=None, total=None, tags=(), tag=None, page=1) -> ArticlePage:
    items = [card()] if items is None else list(items)
    return ArticlePage(
        total=len(items) if total is None else total,
        limit=seo.ARTICLES_PAGE_SIZE,
        offset=(page - 1) * seo.ARTICLES_PAGE_SIZE,
        items=items,
        tags=list(tags),
        tag=tag,
    )


def _jsonld(page: str) -> list[dict]:
    return [json.loads(m) for m in re.findall(r'<script type="application/ld\+json">(.*?)</script>', page)]


class TestUpdatedRule:
    def test_an_edit_on_publication_day_is_not_an_update(self):
        same_day = datetime(2026, 9, 1, 23, 59, tzinfo=UTC)
        assert seo.updated_after_published(PUBLISHED, same_day) is False
        assert seo.article_meta(article(updated_at=same_day)) == "1 Sep 2026 · Duty Free Professor · 1 min read"

    def test_a_later_day_is(self):
        assert seo.updated_after_published(PUBLISHED, EDITED) is True
        assert seo.article_meta(article()) == "1 Sep 2026 · Duty Free Professor · 1 min read · Updated 3 Sep 2026"

    def test_days_are_counted_in_utc_like_the_spa(self):
        """23:30 UTC on the 1st and 00:30 UTC on the 2nd are different days both sides."""
        assert seo.updated_after_published(
            datetime(2026, 9, 1, 23, 30, tzinfo=UTC), datetime(2026, 9, 2, 0, 30, tzinfo=UTC)
        ) is True


class TestArticleHead:
    def test_title_description_canonical_and_seed(self):
        head = seo.head_for_article(article())
        assert head.title == "How to read a duty-free price | Duty Free Professor"
        assert head.description == "A price is an observation with a date, never a quote."
        assert head.canonical_path == "/articles/how-to-read-a-duty-free-price"
        assert head.seed_key == "__DFP_ARTICLE__" and head.seed["slug"] == "how-to-read-a-duty-free-price"
        assert head.last_modified == EDITED

    def test_article_markup_with_absolute_urls_and_both_dates(self):
        page = seo.head_for_article(article()).apply(SHELL, "https://s.example")
        article_ld, crumbs = _jsonld(page)
        assert article_ld["@type"] == "Article"
        assert article_ld["@id"] == "https://s.example/articles/how-to-read-a-duty-free-price#article"
        assert article_ld["mainEntityOfPage"]["@id"] == "https://s.example/articles/how-to-read-a-duty-free-price"
        assert article_ld["headline"] == "How to read a duty-free price"
        assert article_ld["datePublished"] == "2026-09-01T09:30:00+00:00"
        assert article_ld["dateModified"] == "2026-09-03T16:00:00+00:00"
        assert article_ld["publisher"] == {"@id": "https://s.example/#organization"}
        assert article_ld["articleSection"] == "Duty free basics"
        assert "image" not in article_ld
        assert crumbs["itemListElement"][0]["item"] == "https://s.example/articles"
        assert crumbs["itemListElement"][1]["name"] == "How to read a duty-free price"
        assert '<meta property="og:type" content="article" />' in page

    def test_a_hero_image_is_the_page_image_too(self):
        page = seo.head_for_article(article(hero_image="/articles/reading-a-price.jpg")).apply(SHELL, "https://s.example")
        assert _jsonld(page)[0]["image"] == "https://s.example/articles/reading-a-price.jpg"
        assert '<meta property="og:image" content="/articles/reading-a-price.jpg" />' in page
        assert '<figure class="article-page__hero"><img src="/articles/reading-a-price.jpg" alt="" /></figure>' in page

    def test_a_supplied_hero_prints_its_credit_as_a_caption(self):
        """A supplied picture is licensed, not ours, and its licence is permission held by the
        client. A hero with a stored credit and no caption under it is the one way that
        permission is exceeded, so the crawler body draws the same figcaption ArticlePage.tsx
        does, and a hero with no credit draws no empty caption."""
        page = seo.article_body(article(
            hero_image="/uploads/images/line/drambuie-a8be8b01.webp",
            hero_credit="William Grant & Sons, supplied by the client",
        ))
        assert (
            '<figure class="article-page__hero">'
            '<img src="/uploads/images/line/drambuie-a8be8b01.webp" alt="" />'
            '<figcaption class="article-page__hero-credit">'
            "William Grant &amp; Sons, supplied by the client</figcaption></figure>"
        ) in page
        plain = seo.article_body(article(hero_image="/x.webp"))
        assert "figcaption" not in plain and '<img src="/x.webp" alt="" /></figure>' in plain
        assert "figcaption" not in seo.article_body(article())

    def test_body_is_article_page_class_for_class(self):
        body = seo.article_body(article(), {"myAirports": True, "teasers": True})
        assert body.startswith('<div class="announce">')
        assert (
            '<main><article class="article-page"><header class="article-page__head">'
            '<a class="eyebrow eyebrow--ruled article-page__kicker" href="/articles">Duty free basics</a>'
            '<h1 class="article-page__title">How to read a duty-free price</h1>'
            '<p class="article-page__standfirst">A price is an observation with a date, never a quote.</p>'
            '<p class="article-page__meta">1 Sep 2026 · Duty Free Professor · 1 min read · Updated 3 Sep 2026</p></header>'
            '<hr class="article-page__rule" />'
            '<div class="article-page__layout"><div class="article-page__main">'
            '<div class="prose prose--article"><p>Airport prices vary by <em>destination</em> and tier.</p></div>'
            '<aside class="article-page__about"><span class="eyebrow">About Duty Free Professor</span>'
            "<p>We compare the public shelf prices of airport duty-free shops, bottle by bottle and dated, "
            "and set beside them the medals from the Professor's own wine and spirits competitions.</p></aside>"
            "</div></div>"
            '<div class="article-page__sponsors"></div>'
            '<footer class="article-page__foot"><a href="/articles" class="btn btn--ghost">All articles</a></footer>'
            "</article></main>"
        ) in body

    def test_the_byline_names_the_author_account_and_the_reading_time(self):
        """The Professor sites print "date · author" on every piece; an article
        with an author account names it, one without takes the brand byline."""
        body = seo.article_body(article(author="Adam", reading_minutes=6))
        assert '<p class="article-page__meta">1 Sep 2026 · Adam · 6 min read · Updated 3 Sep 2026</p>' in body

    def test_no_category_no_standfirst(self):
        body = seo.article_body(article(category=None, standfirst=None))
        assert 'href="/articles">Article</a>' in body
        assert "article-page__standfirst" not in body

    def test_untrusted_title_is_escaped_in_html_and_json(self):
        head = seo.head_for_article(article(title='Best <script>alert(1)</script> "deals"'))
        page = head.apply(SHELL, "https://s.example")
        assert "<script>alert(1)</script>" not in page
        assert "&lt;script&gt;" in page
        assert _jsonld(page)[0]["headline"] == 'Best <script>alert(1)</script> "deals"'


class TestEditorialBlock:
    def test_airport_write_up_markup_matches_the_component(self):
        note = article(kind="airport_writeup", airport_code="LHR", title="Heathrow: where to start")
        assert seo.editorial_block_html(note) == (
            '<section class="editorial-block"><span class="eyebrow">From the Professor</span>'
            '<h2 class="editorial-block__title">Heathrow: where to start</h2>'
            '<div class="prose"><p>Airport prices vary by <em>destination</em> and tier.</p></div></section>'
        )

    def test_airport_body_shows_the_write_up_once_and_only_when_present(self):
        from test_seo_airport import airport

        plain = seo.airport_body(airport())
        assert "editorial-block" not in plain
        with_note = seo.airport_body(airport(writeup=article(kind="airport_writeup", airport_code="LHR", title="Heathrow: where to start")))
        assert with_note.count('<section class="editorial-block">') == 1
        # After the facts and the terminals, before the shelves, as in AirportPage.tsx.
        assert with_note.index("airport-facts") < with_note.index("editorial-block") < with_note.index("shelf-tabs")


class TestFeedAndSitemap:
    def test_articles_join_the_channel_newest_first(self, monkeypatch):
        added = datetime(2026, 9, 2, 12, 0, tzinfo=UTC)
        monkeypatch.setattr(catalog_queries, "newest_product_variants", lambda db, limit: [(summary(), added)])
        monkeypatch.setattr(editorial, "feed_items", lambda db, limit=20: [article(), article(slug="older", path="/articles/older", title="Older", published_at=datetime(2026, 8, 20, tzinfo=UTC))])
        items = feeds.feed_items(None)
        assert [i.path for i in items] == [
            "/products/santa-teresa-1796-solera-rum-1l-1156", "/articles/how-to-read-a-duty-free-price", "/articles/older",
        ]
        piece = items[1]
        assert piece.title == "How to read a duty-free price" and piece.guid == piece.path
        assert piece.published == PUBLISHED
        assert piece.description == "A price is an observation with a date, never a quote."
        xml = feeds.rss_xml(items, "https://s.example")
        assert "<link>https://s.example/articles/how-to-read-a-duty-free-price</link>" in xml
        assert "<em>" not in xml  # the description, never the body

    def test_sitemap_lists_every_published_article_and_the_centre_only_once_approved(self, monkeypatch):
        # Nothing generated is approved for indexing (Stream K6): an article a person wrote
        # still indexes on publish, with no decision of its own. The centre they sit in is the
        # exception both ways round (AW5.3): it waits to be named, they never do.
        from app.services import publish

        monkeypatch.setattr(seo, "indexed_airport_codes", lambda db: set())
        monkeypatch.setattr(seo, "indexed_brand_slugs", lambda db: set())
        monkeypatch.setattr(seo, "indexed_line_rows", lambda db, since=None: [])
        monkeypatch.setattr(editorial, "sitemap_rows", lambda db: [("/articles/how-to-read-a-duty-free-price", EDITED)])
        xml, newest = seo.sitemap_entries(None, "https://s.example")
        assert "<url><loc>https://s.example/articles</loc></url>" not in xml
        assert "<url><loc>https://s.example/articles/how-to-read-a-duty-free-price</loc><lastmod>2026-09-03</lastmod></url>" in xml
        assert newest == EDITED
        monkeypatch.setattr(publish, "INDEXED_PAGES", frozenset({"/articles"}))
        monkeypatch.setattr(seo, "indexed_category_rows", lambda db: [])  # no category is approved here
        assert "<url><loc>https://s.example/articles</loc></url>" in seo.sitemap_entries(None, "https://s.example")[0]


class TestTheArticleCentre:
    """`/articles` as a page of its own (AW5.3): the grid a crawler sees, the tag rail, the
    ItemList, and the one hub that waits for a person to approve its address."""

    def test_the_body_is_articles_page_class_for_class(self):
        second = card(slug="allowances", path="/articles/allowances", title="What you may carry home",
                      standfirst=None, excerpt="Allowances differ by destination.", category="Duty free basics",
                      tags=["allowances"], reading_minutes=3)
        body = seo.articles_body(listing([card(tags=["prices"]), second], total=2), 1, None, {})
        assert body.startswith('<div class="announce">')
        assert (
            '<main><div class="page stack articles-page">'
            '<header class="articles-page__head">'
            '<span class="eyebrow eyebrow--ruled">From the Professor</span>'
            '<h1 class="articles-page__title">Articles</h1>'
            '<p class="articles-page__lede">Guides, news and notes from the Duty Free Professor, '
            'written to sit next to the live prices.</p></header>'
        ) in body
        # The newest piece leads, large, picture beside the words; the rest are the grid.
        assert (
            '<a class="article-card article-card--lead" href="/articles/how-to-read-a-duty-free-price">'
            '<div class="article-card__thumb"><span class="article-card__art" aria-hidden="true">'
            '<svg class="line-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
            '<path d="M4 5h13v14H6a2 2 0 0 1-2-2z M17 9h3v8a2 2 0 0 1-2 2 M7 8h7 M7 11h7 M7 14h4"></path>'
            '</svg></span></div><div class="article-card__body">'
            '<span class="article-card__kind">Duty free basics</span>'
            '<h3 class="article-card__title">How to read a duty-free price</h3>'
            '<p class="article-card__excerpt">A price is an observation with a date, never a quote.</p>'
            '<span class="article-card__meta">1 Sep 2026 · Duty Free Professor · 1 min read</span>'
            '<span class="article-card__tags"><span class="article-card__tag">prices</span></span>'
            "</div></a>"
        ) in body
        assert '<div class="articles-page__grid"><a class="article-card" href="/articles/allowances">' in body
        assert "article-card--lead" not in body.split('<div class="articles-page__grid">')[1]

    def test_a_card_with_a_picture_draws_it_and_no_stand_in(self):
        body = seo.articles_body(listing([card(hero_image="/uploads/images/line/x-a8be8b01.webp")]), 1, None, {})
        assert ('<div class="article-card__thumb"><img class="article-card__image" '
                'src="/uploads/images/line/x-a8be8b01.webp" alt="" loading="lazy" decoding="async" />') in body
        assert "article-card__art" not in body

    def test_a_sample_is_marked_on_its_card_and_at_the_top_of_its_page(self):
        """The client reads staging. A sample the launch step will take to draft must never be
        mistaken for his own text, so it says what it is in both places it can be seen."""
        body = seo.articles_body(listing([card(sample=True)]), 1, None, {})
        assert '<span class="article-card__flag"><span class="badge badge--warn">Sample</span></span>' in body
        assert "article-card__flag" not in seo.articles_body(listing([card()]), 1, None, {})
        piece = seo.article_body(article(sample=True))
        assert ('<article class="article-page">'
                '<p class="article-page__sample">Sample article: a draft the Professor may replace</p>') in piece
        assert "article-page__sample" not in seo.article_body(article())

    def test_the_tag_rail_marks_the_filter_and_the_pager_keeps_it(self):
        body = seo.articles_body(listing([card()], total=30, tags=["allowances", "whisky"], tag="whisky", page=2), 2, "whisky", {})
        assert (
            '<nav class="articles-page__tags" aria-label="Filter by tag">'
            '<a class="filter-chip" href="/articles">All</a>'
            '<a class="filter-chip" href="/articles?tag=allowances">allowances</a>'
            '<a class="filter-chip filter-chip--active" href="/articles?tag=whisky">whisky</a></nav>'
        ) in body
        assert '<a class="btn btn--ghost" href="/articles?tag=whisky">Newer</a>' in body
        assert '<a class="btn btn--ghost" href="/articles?tag=whisky&amp;page=3">Older</a>' in body
        assert '<span class="muted">Page 2 of 3</span>' in body
        # Page two is all grid: the lead card belongs to the first page only.
        assert "article-card--lead" not in body

    def test_an_empty_room_says_which_kind_of_empty(self):
        assert "No articles have been published yet." in seo.articles_body(listing([], total=0), 1, None, {})
        assert "No articles carry that tag yet." in seo.articles_body(listing([], total=0, tag="gin"), 1, "gin", {})

    def test_the_item_list_names_every_piece_on_the_page(self):
        page = seo.head_for_articles(listing([card(), card(slug="two", path="/articles/two", title="Two",
                                                          hero_image="/media/articles/two.svg")]), 1, None).apply(SHELL, "https://s.example")
        collection, crumbs = _jsonld(page)
        assert collection["@type"] == "CollectionPage"
        assert collection["url"] == "https://s.example/articles"
        items = collection["mainEntity"]["itemListElement"]
        assert collection["mainEntity"]["numberOfItems"] == 2
        assert [i["position"] for i in items] == [1, 2]
        assert items[0]["item"]["headline"] == "How to read a duty-free price"
        assert items[0]["item"]["url"] == "https://s.example/articles/how-to-read-a-duty-free-price"
        assert items[0]["item"]["datePublished"] == "2026-09-01T09:30:00+00:00"
        assert "image" not in items[0]["item"]
        assert items[1]["item"]["image"] == "https://s.example/media/articles/two.svg"
        assert crumbs["itemListElement"][1]["name"] == "Articles"

    def test_the_position_counts_on_past_the_first_page(self):
        head = seo.head_for_articles(listing([card()], total=30, page=3), 3, None)
        assert head.jsonld[0]["mainEntity"]["itemListElement"][0]["position"] == 25

    def test_every_view_canonicalises_to_the_centre_and_seeds_what_it_drew(self):
        head = seo.head_for_articles(listing([card()], total=30, tag="whisky", page=2), 2, "whisky")
        assert head.canonical_path == "/articles"
        assert head.seed_key == "__DFP_ARTICLES__"
        assert head.seed["offset"] == seo.ARTICLES_PAGE_SIZE and head.seed["tag"] == "whisky"
        assert head.seed["items"][0]["slug"] == "how-to-read-a-duty-free-price"
        assert head.last_modified == EDITED

    def test_the_centre_is_noindex_until_a_person_names_it_though_its_pieces_index(self, monkeypatch):
        from app.services import publish

        head = seo.head_for_articles(listing())
        assert (head.noindex, head.follow) == (True, True)
        assert seo.head_for_article(article()).noindex is False
        monkeypatch.setattr(publish, "INDEXED_PAGES", frozenset({"/articles"}))
        assert seo.head_for_articles(listing()).noindex is False

    def test_the_sitemap_lists_the_centre_only_once_it_is_approved(self, monkeypatch):
        from app.services import publish

        assert "/articles" not in seo.static_sitemap_paths()
        monkeypatch.setattr(publish, "INDEXED_PAGES", frozenset({"/articles"}))
        assert "/articles" in seo.static_sitemap_paths()

    def test_a_title_from_a_hand_in_is_escaped_in_the_grid(self):
        body = seo.articles_body(listing([card(title='Best <script>alert(1)</script> "deals"')]), 1, None, {})
        assert "<script>alert(1)</script>" not in body and "&lt;script&gt;" in body
