"""The crawl surface (B7): robots.txt by bot, IndexNow, the RSS feed, llms.txt
and the Dataset page.

Each of these is read by a machine that never runs the app, and each is built
from the database or from one policy table, so the tests pin what the machine
receives: the policy in the file matches the switches, the feed and llms.txt
carry absolute links and escaped text, IndexNow posts the protocol's exact
payload in batches and refuses to run half-configured, and the data page's
figures and markup come from the facts object and nothing else. No database.
"""

import json
import re
from datetime import UTC, datetime

import pytest

from app import cli_pages
from app.models.hubs import AirportSummary, DatasetFacts
from app.models.schemas import CategoryCount
from app.services import catalog_queries, editorial, feeds, indexnow, robots_policy, seo
from app.services.collectors import robots as our_robots
from test_seo_airport import SHELL, summary

FIRST = datetime(2026, 8, 23, 3, 43, tzinfo=UTC)
LAST = datetime(2026, 8, 25, 23, 51, tzinfo=UTC)
BASE = "https://s.example"


def facts(**over) -> DatasetFacts:
    base = dict(
        observations=2315, product_variants=500, airports=4, retailers=1, currencies=3, with_barcode=200,
        awards=39, first_observed_at=FIRST, last_observed_at=LAST,
        airport_list=[
            AirportSummary(iata="LHR", path="/airports/heathrow-lhr-london", name="London Heathrow", city="London",
                           country="United Kingdom", currency="GBP", product_variants=266, last_collected_at=LAST),
            AirportSummary(iata="JFK", path="/airports/jfk-new-york", name="New York JFK", city="New York",
                           country="United States", currency="USD", product_variants=191, last_collected_at=FIRST),
        ],
        categories=[CategoryCount(category="Whisky", count=114), CategoryCount(category="Gin & Tonic", count=31)],
    )
    base.update(over)
    return DatasetFacts(**base)


class TestRobotsPolicy:
    def test_everyone_gets_the_signals_and_the_api_stays_out(self):
        text = robots_policy.robots_txt(BASE)
        assert "User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=no\nAllow: /\nDisallow: /api/" in text
        assert text.rstrip().endswith("Sitemap: https://s.example/sitemap.xml")

    def test_training_only_bots_follow_the_ai_train_switch(self, monkeypatch):
        closed = robots_policy.robots_txt(BASE)
        assert "User-agent: GPTBot\nDisallow: /" in closed
        assert "User-agent: CCBot\nDisallow: /" in closed
        monkeypatch.setitem(robots_policy.CONTENT_SIGNALS, "ai-train", True)
        opened = robots_policy.robots_txt(BASE)
        assert "ai-train=yes" in opened
        assert "User-agent: GPTBot\nAllow: /\nDisallow: /api/" in opened

    def test_a_grounding_token_stays_open_while_ai_input_is_yes(self):
        """Google-Extended switches off Gemini grounding as well as training;
        closing it would undo the answer-engine work the site exists for."""
        assert "User-agent: Google-Extended\nAllow: /" in robots_policy.robots_txt(BASE)

    def test_no_sitemap_line_without_a_configured_origin(self):
        assert "Sitemap:" not in robots_policy.robots_txt("")

    def test_our_own_matcher_reads_our_file_the_way_we_mean_it(self, monkeypatch):
        """The collectors' parser is the standard the file is written to: a bot
        addressed by name gets its own group, everyone else the star group.
        Our matcher evaluates for BOT_NAME, so name it after a listed bot to
        read that bot's group, and after nobody to read the star group."""
        text = robots_policy.robots_txt(BASE)
        star = our_robots.parse(text, host="s.example")
        assert star.allows("/products/x-1") and not star.allows("/api/products")
        monkeypatch.setattr(our_robots, "BOT_NAME", "GPTBot")
        assert not our_robots.parse(text, host="s.example").allows("/products/x-1")
        monkeypatch.setattr(our_robots, "BOT_NAME", "Google-Extended")
        assert our_robots.parse(text, host="s.example").allows("/products/x-1")


class TestFeed:
    def test_item_wording_for_one_shop_and_for_several(self):
        one = feeds.product_item(summary(shop_count=1, cheapest_usd=7.5, dearest_usd=7.5,
                                         best_shop_iata="JFK", top_prices=[]), LAST)
        assert one.description == "Santa Teresa, Rum, 1L. Priced at JFK: $7.50. Every price is dated on the page."
        many = feeds.product_item(summary(), LAST)
        assert many.description == (
            "Santa Teresa, Rum, 1L. Priced at 3 airport shops, from $38.00 to $55.50; cheapest at LHR. "
            "Every price is dated on the page."
        )
        assert many.path == many.guid == "/products/santa-teresa-1796-solera-rum-1l-1156"

    def test_rss_is_absolute_dated_and_escaped(self):
        item = feeds.FeedItem(title='Gin <b>"Special"</b> & Tonic', path="/products/gin-1", published=LAST,
                              description="A & B", guid="/products/gin-1")
        xml = feeds.rss_xml([item], BASE)
        assert xml.startswith('<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"')
        assert "<link>https://s.example/</link>" in xml
        assert '<atom:link href="https://s.example/feed.xml" rel="self" type="application/rss+xml" />' in xml
        assert "<lastBuildDate>Tue, 25 Aug 2026 23:51:00 GMT</lastBuildDate>" in xml
        assert "<title>Gin &lt;b&gt;&quot;Special&quot;&lt;/b&gt; &amp; Tonic</title>" in xml
        assert '<guid isPermaLink="true">https://s.example/products/gin-1</guid>' in xml
        assert "<pubDate>Tue, 25 Aug 2026 23:51:00 GMT</pubDate>" in xml
        assert "<description>A &amp; B</description>" in xml
        assert "<b>" not in xml

    def test_feed_items_come_from_the_newest_product_variants(self, monkeypatch):
        monkeypatch.setattr(catalog_queries, "newest_product_variants", lambda db, limit: [(summary(), LAST)])
        monkeypatch.setattr(editorial, "feed_items", lambda db, limit=20: [])  # articles: test_seo_article.py
        items = feeds.feed_items(None)
        assert [i.title for i in items] == ["Santa Teresa 1796 Solera Rum 1L"] and items[0].published == LAST


class TestLlmsTxt:
    def test_figures_links_and_airports_are_the_facts(self):
        text = feeds.llms_txt(facts(), BASE)
        assert text.startswith("# Duty Free Professor\n\n> Duty-free price comparison")
        assert ("- 500 products at 4 airports, 2,315 dated price observations in 3 currencies, 200 products "
                "with a barcode, 39 competition medals attached; observed 23 Aug 2026 to 25 Aug 2026.") in text
        assert "- [London Heathrow (LHR)](https://s.example/airports/heathrow-lhr-london): 266 products, checked 25 Aug 2026." in text
        assert "- [Gin & Tonic](https://s.example/products?category=Gin%20%26%20Tonic): 31 products" in text
        assert "- Sitemap: https://s.example/sitemap.xml" in text
        assert "- Feed: https://s.example/feed.xml" in text
        assert "https://bot.dutyfreeprofessor.com" in text
        assert "—" not in text

    def test_no_typed_numbers(self):
        """Every figure in the briefing changes with the facts; nothing is hard-coded."""
        a = feeds.llms_txt(facts(), BASE)
        b = feeds.llms_txt(facts(observations=1, product_variants=2, airports=3, currencies=4, with_barcode=5, awards=6,
                                 airport_list=[], categories=[]), BASE)
        for n in ("2,315", "500", "266", "114"):
            assert n in a and n not in b, n


class FakeDB:
    """The two reads changed_urls makes, in order: touched shops, then products."""

    def __init__(self, touched, product_variants):
        self._touched, self._product_variants = touched, product_variants

    def scalars(self, stmt):
        return iter(self._touched)

    def execute(self, stmt):
        rows = self._product_variants

        class Result:
            @staticmethod
            def all():
                return rows

        return Result()


class TestIndexNow:
    def _airports(self, monkeypatch):
        class Loc:
            def __init__(self, id, iata, city, name):
                self.id, self.iata, self.city, self.name = id, iata, city, name

        monkeypatch.setattr(catalog_queries, "airport_shops",
                            lambda db, iata=None: [Loc(13, "LHR", "London", "London Heathrow"), Loc(30, "JFK", "New York", "JFK")])
        # No shop is publication-blocked here; the block itself is pinned in test_publication_block.py.
        monkeypatch.setattr(catalog_queries, "blocked_shop_ids", lambda db: [])

    def test_changed_urls_are_the_indexed_pages_only_sitemap_then_airports_then_product_lines(self, monkeypatch):
        """Stream K6 (plan W18): only a page a person approved is submitted. Before, every changed
        airport and every changed VARIANT address went out, each of the latter a 301."""
        from app.services import seo

        self._airports(monkeypatch)
        monkeypatch.setattr(seo, "indexed_airport_codes", lambda db: {"LHR"})
        monkeypatch.setattr(seo, "indexed_line_rows", lambda db, since=None: [("/products/johnnie-walker-red-label", since)])
        db = FakeDB(touched=[13, 30], product_variants=[(4, "Johnnie Walker Red Label 1L")])
        assert indexnow.changed_urls(db, LAST, BASE) == [
            "https://s.example/sitemap.xml",
            "https://s.example/airports/heathrow-lhr-london",
            "https://s.example/products/johnnie-walker-red-label",
        ], "JFK changed too, and nobody approved it"
        monkeypatch.setattr(seo, "indexed_airport_codes", lambda db: set())
        monkeypatch.setattr(seo, "indexed_line_rows", lambda db, since=None: [])
        assert indexnow.changed_urls(db, LAST, BASE) == [], "nothing approved, nothing submitted"
        with pytest.raises(ValueError):
            indexnow.changed_urls(db, LAST, "")

    def test_with_the_line_pages_off_the_variant_pages_are_submitted_again(self, monkeypatch):
        from app.services import seo

        self._airports(monkeypatch)
        monkeypatch.setattr(seo, "indexed_airport_codes", lambda db: set())
        db = FakeDB(touched=[13], product_variants=[(4, "Johnnie Walker Red Label 1L"), (17, "Hennessy Paradis")])
        assert indexnow.changed_urls(db, LAST, BASE, line_pages=False) == [
            "https://s.example/sitemap.xml",
            "https://s.example/products/johnnie-walker-red-label-1l-4",
            "https://s.example/products/hennessy-paradis-17",
        ]

    def test_an_approval_pings_once_and_only_where_the_host_has_a_key(self):
        posted = []

        def post(url, body):
            posted.append(json.loads(body)["urlList"])
            return 202

        assert indexnow.ping_approved(["/brands/macallan"], BASE, "abc123", post=post) == [(2, 202)]
        assert posted == [[f"{BASE}/sitemap.xml", f"{BASE}/brands/macallan"]]
        assert indexnow.ping_approved(["/brands/macallan"], BASE, "", post=post) == [], "staging has no key: silent"
        assert indexnow.ping_approved([], BASE, "abc123", post=post) == [] and len(posted) == 1

        def broken(url, body):
            raise OSError("no network")

        assert indexnow.ping_approved(["/brands/macallan"], BASE, "abc123", post=broken) == [], "a failed ping never fails the approval"

    def test_submit_posts_the_protocol_payload_in_batches(self, monkeypatch):
        monkeypatch.setattr(indexnow, "BATCH", 2)
        posted = []

        def post(url, body):
            posted.append((url, json.loads(body)))
            return 202

        urls = [f"{BASE}/products/p-{i}" for i in range(5)]
        assert indexnow.submit(urls, BASE, "abc123", post=post) == [(2, 202), (2, 202), (1, 202)]
        assert posted[0][0] == "https://api.indexnow.org/indexnow"
        assert posted[0][1] == {
            "host": "s.example", "key": "abc123", "keyLocation": "https://s.example/abc123.txt",
            "urlList": urls[:2],
        }
        assert posted[2][1]["urlList"] == urls[4:]

    def test_submit_refuses_without_a_key(self):
        with pytest.raises(ValueError):
            indexnow.submit([f"{BASE}/x"], BASE, "", post=lambda u, b: 200)

    def test_command_dry_run_lists_and_never_posts(self, monkeypatch, capsys):
        monkeypatch.setattr(cli_pages.settings, "public_base_url", BASE)
        monkeypatch.setattr(cli_pages.settings, "indexnow_key", "abc123")
        monkeypatch.setattr(cli_pages, "SessionLocal", lambda: FakeDB([], []))
        monkeypatch.setattr(indexnow, "changed_urls", lambda db, since, base, line_pages=True: [f"{base}/sitemap.xml", f"{base}/products/x-1"])
        monkeypatch.setattr(indexnow, "submit", lambda *a, **k: pytest.fail("posted during a dry run"))
        FakeDB.__enter__ = lambda self: self
        FakeDB.__exit__ = lambda self, *exc: None
        assert cli_pages.main(["indexnow", "--since", "2026-09-05T13:20:00+00:00", "--dry-run"]) == 0
        out = capsys.readouterr().out
        assert "2 page(s) changed since 2026-09-05T13:20+00:00" in out and f"{BASE}/products/x-1" in out

    def test_command_stops_when_half_configured(self, monkeypatch, capsys):
        monkeypatch.setattr(cli_pages.settings, "public_base_url", "")
        assert cli_pages.main(["indexnow", "--hours", "1"]) == 2
        assert "PUBLIC_BASE_URL" in capsys.readouterr().err
        monkeypatch.setattr(cli_pages.settings, "public_base_url", BASE)
        monkeypatch.setattr(cli_pages.settings, "indexnow_key", "REPLACE_WITH_KEY")
        monkeypatch.setattr(cli_pages, "SessionLocal", lambda: FakeDB([], []))
        monkeypatch.setattr(indexnow, "changed_urls", lambda db, since, base, line_pages=True: [f"{base}/sitemap.xml"])
        assert cli_pages.main(["indexnow", "--hours", "1"]) == 2
        assert "INDEXNOW_KEY" in capsys.readouterr().err

    def test_placeholder_key_reads_as_absent(self, monkeypatch):
        from app.config import Settings

        assert not Settings(indexnow_key="REPLACE_WITH_KEY").has_indexnow
        assert not Settings(indexnow_key="").has_indexnow
        assert not Settings(indexnow_key="../etc").has_indexnow
        assert Settings(indexnow_key="abc123def456").has_indexnow


class TestDatasetPage:
    def test_dataset_markup_states_only_what_we_hold(self):
        page = seo.head_for_dataset(facts()).apply(SHELL, BASE)
        blocks = [json.loads(m) for m in re.findall(r'<script type="application/ld\+json">(.*?)</script>', page)]
        dataset = next(b for b in blocks if b["@type"] == "Dataset")
        assert dataset["@id"] == "https://s.example/data#dataset" and dataset["url"] == "https://s.example/data"
        assert dataset["creator"] == {"@id": "https://s.example/#organization"}
        assert dataset["temporalCoverage"] == "2026-08-23/2026-08-25" and dataset["dateModified"] == "2026-08-25"
        assert dataset["isAccessibleForFree"] is True
        assert "license" not in dataset and "distribution" not in dataset
        assert [a["iataCode"] for a in dataset["spatialCoverage"]] == ["LHR", "JFK"]
        assert dataset["spatialCoverage"][0]["url"] == "https://s.example/airports/heathrow-lhr-london"
        assert [v["name"] for v in dataset["variableMeasured"]][:3] == ["product", "shop", "price"]
        assert dataset["size"] == "2,315 price observations across 500 products"
        assert "DutyFreeProfessorBot" in dataset["measurementTechnique"]

    def test_body_figures_are_the_facts(self):
        body = seo.dataset_body(facts())
        for label, value in (("Price observations", "2,315"), ("Products", "500"), ("Airports", "4"),
                             ("Currencies", "3"), ("Products with a barcode", "200"), ("Competition medals attached", "39")):
            assert f"<dt>{label}</dt><dd>{value}</dd>" in body, label
        assert "Observed from 23 Aug 2026 to 25 Aug 2026" in body
        assert '<a href="/airports/heathrow-lhr-london">London Heathrow (LHR)</a>: 266 products, checked 25 Aug 2026' in body
        assert '<a href="/products?category=Gin%20%26%20Tonic">Gin &amp; Tonic</a>: 31' in body
        assert '<th scope="row">observed at</th>' in body
        assert "mailto:bot@dutyfreeprofessor.com" in body
        assert "—" not in body

    def test_no_coverage_dates_means_no_temporal_claim(self):
        head = seo.head_for_dataset(facts(first_observed_at=None, last_observed_at=None))
        assert "temporalCoverage" not in head.jsonld[0] and "dateModified" not in head.jsonld[0]
        assert "Observed from not yet to not yet" in head.body

    def test_head_seed_and_last_modified(self):
        head = seo.head_for_dataset(facts())
        assert head.canonical_path == "/data" and head.seed_key == "__DFP_DATASET__"
        assert head.seed["observations"] == 2315 and head.last_modified == LAST
        assert head.title == "The data: airport duty-free price observations | Duty Free Professor"
        assert "—" not in head.title and "—" not in head.description


class TestFeedLinkInEveryHead:
    def test_alternate_link_points_at_the_feed(self):
        page = seo.Head("T", "D", canonical_path="/x").apply(SHELL, BASE)
        assert ('<link rel="alternate" type="application/rss+xml" title="Duty Free Professor: new in duty free" '
                'href="https://s.example/feed.xml" />') in page
        assert 'rel="alternate"' not in seo.Head("T", "D").apply(SHELL, "")
