"""What one address is, and the badge that shows it: the four words, the read, the backfill.

`publish.status_of_path` is a second opinion about every page, so the risk is that it drifts
from the page itself and tells the owner a page indexes when its head says `noindex`. The
consistency test here holds it against the head builders and against `sitemap_entries` for one
address of every kind. The other risk is the mark leaking into a served body, which would be
cloaking: a crawler is never shown anything a visitor is not. Grep pins that too.

SQLite kit, no network, no server.
"""

from __future__ import annotations

import pathlib
import re
from datetime import UTC, datetime

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from app.models import (Account, AccountLevel, Award, Base, Brand, CollectionRun, LEDGER_TABLES, Listing,
                        PriceObservation, ProductLine, ProductVariant, Retailer, Shop, Source, Suggestion)
from app.models.editorial import Article
from app.models.hours import AirportHours
from app.models.places import Place, ShopPlace
from app.models.quality import VerificationCheck
from app.services import publish, seo

MAIN = pathlib.Path(__file__).resolve().parents[1]
#: The badge's own class name. It may be NAMED in a comment or a docstring on the server side
#: (that is how the rule is explained); what must never happen is a server-rendered body
#: carrying it, so the grep reads code and string content with the prose stripped out.
MARK = "page-status"
PY_PROSE = re.compile(r'^[ \t]*#.*$|"""(?:.|\n)*?"""', re.M)


def _code_only(source: str) -> str:
    return PY_PROSE.sub("", source)


NOW = datetime(2026, 9, 17, tzinfo=UTC)
#: The kit the resolver, the head builders and the sitemap read; SQLite cannot compile every
#: table in the metadata (a JSONB column in the verification tables), so they are named.
TABLES = [Account.__table__, AccountLevel.__table__, Source.__table__, CollectionRun.__table__, Brand.__table__,
          ProductLine.__table__, ProductVariant.__table__, Retailer.__table__, Shop.__table__, Listing.__table__,
          PriceObservation.__table__, Suggestion.__table__, Place.__table__, ShopPlace.__table__,
          Article.__table__, AirportHours.__table__, Award.__table__, VerificationCheck.__table__, *LEDGER_TABLES]


#: The pairing page `/airports/<airport>/<category>` ships behind a flag and a bar of fifteen
#: published product variants (`coverage`); the kit below prices two, so a pair has a page here
#: only with both moved. Off, the address is a real 404, which is what the badge must say.
PAIR_PATH = "/airports/heathrow-lhr-london/whisky"


def _pairing_pages_on(monkeypatch) -> None:
    from app.config import settings
    from app.services import coverage

    monkeypatch.setattr(settings, "feature_category_at_airport", True)
    monkeypatch.setattr(coverage, "CATEGORY_AT_AIRPORT_MIN_PRODUCTS", 1)


def _price(s, variant_id: int, shop_id: int) -> None:
    listing = Listing(variant_id=variant_id, shop_id=shop_id, source_sku=f"v{variant_id}s{shop_id}", listed_name="x")
    s.add(listing)
    s.flush()
    s.add(PriceObservation(listing_id=listing.id, price=10, currency="USD", price_usd=10, observed_at=NOW))


@pytest.fixture
def db():
    """One brand with two product lines priced at two airports, one hidden brand with its
    alias, one published article and one draft: enough for every branch of the resolver."""
    engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
    Base.metadata.create_all(engine, tables=TABLES)
    with sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)() as s:
        s.add_all([
            Retailer(id=1, slug="r", name="R"),
            Brand(id=1, slug="macallan", name="The Macallan"),
            Brand(id=2, slug="gone", name="Gone", hidden=True),
            Brand(id=3, slug="macallan-distillery", name="Macallan Distillery", alias_of_id=1),
        ])
        s.flush()
        s.add_all([
            Shop(id=1, retailer_id=1, code="LHR-T2", iata="LHR", name="Heathrow T2", city="London", currency="GBP", visible=True),
            Shop(id=2, retailer_id=1, code="CDG", iata="CDG", name="Paris CDG", city="Paris", currency="EUR", visible=True),
        ])
        s.add_all([
            ProductLine(id=1, brand_id=1, key="12", slug="macallan-12", name="12 Year Old"),
            ProductLine(id=2, brand_id=1, key="18", slug="macallan-18", name="18 Year Old", hidden=True),
        ])
        s.add_all([
            Place(id=1, slug="heathrow-lhr-london", kind="airport", name="Heathrow",
                  identifiers=[{"scheme": "iata", "value": "LHR"}]),
            Place(id=2, slug="paris-cdg", kind="airport", name="Paris CDG", hidden=True,
                  identifiers=[{"scheme": "iata", "value": "CDG"}]),
        ])
        s.flush()
        s.add_all([ShopPlace(shop_id=1, place_id=1, role="primary"), ShopPlace(shop_id=2, place_id=2, role="primary")])
        for vid, line_id in ((1, 1), (2, 1)):
            s.add(ProductVariant(id=vid, name=f"Macallan {vid}", brand="The Macallan", brand_id=1, vertical="liquor",
                                 category="Whisky", match_key=f"k{vid}", product_line_id=line_id, form="single", attributes={}))
        s.flush()
        _price(s, 1, 1)
        _price(s, 1, 2)
        _price(s, 2, 1)
        s.add_all([
            Article(slug="is-it-really-a-saving", title="Is it really a saving", body_md="# Is it really a saving\n\nWords.",
                    kind="article", status="published", published_at=NOW, updated_at=NOW),
            Article(slug="not-yet", title="Not yet", body_md="# Not yet\n\nWords.", kind="article", status="draft"),
        ])
        s.commit()
        yield s


def status(db, path: str) -> publish.PageStatus:
    return publish.status_of_path(db, path)


class TestTheFourWords:
    def test_a_generated_page_is_noindex_until_a_person_approves_it(self, db):
        for path in ("/brands/macallan", "/products/macallan-12", "/airports/heathrow-lhr-london"):
            out = status(db, path)
            assert (out.status, out.robots, out.in_sitemap) == ("noindex", publish.ROBOTS_FOLLOW, False), path
            assert out.canonical == path

    def test_an_approved_page_is_indexable(self, db):
        db.get(Brand, 1).indexed = True
        db.get(ProductLine, 1).indexed = True
        db.get(Place, 1).indexed = True
        db.flush()
        for path in ("/brands/macallan", "/products/macallan-12", "/airports/heathrow-lhr-london"):
            out = status(db, path)
            assert (out.status, out.robots, out.in_sitemap) == ("indexable", None, True), path

    def test_a_hidden_page_names_the_target_it_forwards_to(self, db):
        out = status(db, "/brands/gone")
        assert out.status == "hidden" and out.forwards_to == "/products?brand=Gone" and not out.in_sitemap
        out = status(db, "/products/macallan-18")
        assert out.status == "hidden" and out.forwards_to == "/brands/macallan"
        out = status(db, "/airports/paris-cdg")
        assert out.status == "hidden" and out.forwards_to == "/airports"

    def test_an_alias_and_an_old_variant_address_are_hidden_too(self, db):
        out = status(db, "/brands/macallan-distillery")
        assert out.status == "hidden" and out.forwards_to == "/brands/macallan"
        out = status(db, "/products/macallan-1")
        assert out.status == "hidden" and out.forwards_to == "/products/macallan-12?variant=1"
        assert out.canonical == "/products/macallan-12", "a canonical never carries the query the redirect keeps"

    def test_a_client_or_account_surface_is_unlisted(self, db):
        for path in ("/discuss", "/plan", "/login", "/account", "/review", "/why-we-do-this.html"):
            out = status(db, path)
            assert (out.status, out.in_sitemap) == ("unlisted", False), path
            assert out.robots in (publish.ROBOTS_FOLLOW, publish.ROBOTS_NOFOLLOW)

    def test_a_hub_the_site_is_built_around_is_indexable(self, db):
        for path in ("/", "/products", "/airports", "/data"):
            out = status(db, path)
            assert (out.status, out.robots, out.in_sitemap) == ("indexable", None, True), path

    def test_an_address_approved_by_name_is_noindex_until_it_is_named(self, db, monkeypatch):
        for path in ("/alcohol", "/alcohol/whisky", "/how-we-choose", "/articles"):
            assert status(db, path).status == "noindex", path
        monkeypatch.setattr(publish, "INDEXED_PAGES", frozenset({"/alcohol/whisky"}))
        assert status(db, "/alcohol/whisky").status == "indexable"
        assert status(db, "/alcohol").status == "noindex"

    def test_the_article_centre_waits_for_approval_though_its_pieces_do_not(self, db, monkeypatch):
        """`/articles` is the one hub a person approves by name: a room nobody has read should
        not be the address an engine files, while a piece a person wrote and published should."""
        out = status(db, "/articles")
        assert (out.status, out.robots, out.in_sitemap) == ("noindex", publish.ROBOTS_FOLLOW, False)
        assert status(db, "/articles/is-it-really-a-saving").status == "indexable"
        monkeypatch.setattr(publish, "INDEXED_PAGES", frozenset({"/articles"}))
        out = status(db, "/articles")
        assert (out.status, out.robots, out.in_sitemap) == ("indexable", None, True)
        assert "<loc>https://s.example/articles</loc>" in seo.sitemap_entries(db, "https://s.example")[0]

    def test_a_published_article_indexes_on_publish(self, db):
        out = status(db, "/articles/is-it-really-a-saving")
        assert (out.status, out.robots, out.in_sitemap) == ("indexable", None, True)

    def test_a_draft_and_an_unknown_route_are_missing(self, db):
        for path in ("/articles/not-yet", "/articles/never-written", "/no-such-page", "/brands/nobody",
                     "/alcohol/not-a-category", "/airports/zzz-nowhere"):
            out = status(db, path)
            assert out.status == "missing", path
            assert not out.in_sitemap and out.forwards_to is None

    def test_a_query_string_and_a_trailing_slash_name_the_same_page(self, db):
        assert status(db, "/brands/macallan/").path == "/brands/macallan"
        assert status(db, "/brands/macallan?page=2").status == status(db, "/brands/macallan").status
        assert status(db, "/").path == "/"

    def test_a_category_at_an_airport_with_no_page_is_missing_however_the_airport_was_decided(self, db):
        """The badge read the airport's `indexed` column and called the pair indexable and in
        the sitemap, while under the coverage bar the route answers a real 404 and the sitemap
        lists nothing: a mark that told the owner an address indexes when there is no page."""
        db.get(Place, 1).indexed = True
        db.flush()
        out = status(db, PAIR_PATH)
        assert out.status == "missing" and not out.in_sitemap
        assert out.reason == "no page exists for this category at this airport"

    def test_a_category_at_an_airport_rides_on_the_airports_decision(self, db, monkeypatch):
        _pairing_pages_on(monkeypatch)
        assert status(db, PAIR_PATH).status == "noindex"
        db.get(Place, 1).indexed = True
        db.flush()
        out = status(db, PAIR_PATH)
        assert out.status == "indexable" and out.canonical == PAIR_PATH
        assert status(db, "/airports/heathrow-lhr-london/not-a-category").status == "missing"
        # The category word is spelled the one way the page spells it; any other casing is the
        # 301 the route answers, never a second address of the same page.
        assert status(db, "/airports/heathrow-lhr-london/Whisky").forwards_to == PAIR_PATH

    def test_a_stale_airport_address_forwards_to_the_declared_one(self, db):
        out = status(db, "/airports/lhr")
        assert out.status == "hidden" and out.forwards_to == "/airports/heathrow-lhr-london"

    def test_every_status_is_one_of_the_declared_words(self, db):
        paths = ["/", "/brands/macallan", "/brands/gone", "/products/macallan-12", "/articles/not-yet",
                 "/airports/heathrow-lhr-london", "/alcohol/whisky", "/discuss", "/nope"]
        assert {status(db, p).status for p in paths} <= set(publish.STATUSES)


class TestItAgreesWithThePageItself:
    """The resolver calls no head builder, so it can only be kept honest by comparison."""

    def _sitemap(self, db) -> str:
        xml, _ = seo.sitemap_entries(db, "https://s.example")
        return xml

    @pytest.mark.parametrize("indexed", [False, True])
    def test_one_address_of_every_kind_agrees_with_its_head_and_the_sitemap(self, db, indexed):
        db.get(Brand, 1).indexed = indexed
        db.get(ProductLine, 1).indexed = indexed
        db.get(Place, 1).indexed = indexed
        db.flush()
        heads = {
            "/brands/macallan": seo.brand_head(db, "macallan"),
            "/products/macallan-12": seo.line_head(db, "macallan-12"),
            "/airports/heathrow-lhr-london": seo.airport_head(db, "LHR"),
            "/articles/is-it-really-a-saving": seo.article_head(db, "is-it-really-a-saving"),
            "/alcohol/whisky": seo.category_head(db, "whisky"),
        }
        xml = self._sitemap(db)
        for path, head in heads.items():
            assert head is not None, path
            out = status(db, path)
            expected = None if not head.noindex else (publish.ROBOTS_FOLLOW if head.follow else publish.ROBOTS_NOFOLLOW)
            assert out.robots == expected, f"{path}: the badge says {out.robots!r}, the head sends {expected!r}"
            listed = f"<loc>https://s.example{path}</loc>" in xml
            assert out.in_sitemap == listed, f"{path}: the badge says in_sitemap={out.in_sitemap}, the sitemap says {listed}"
            assert (out.status == "indexable") == (expected is None)

    def test_three_static_addresses_agree_with_their_heads_and_the_sitemap(self, db):
        xml = self._sitemap(db)
        # `/articles` left STATIC_HEADS when it gained a page route of its own (AW5.3), so it
        # is compared against the head that route builds, below.
        for path in ("/", "/products", "/discuss"):
            head = seo.STATIC_HEADS[path]
            out = status(db, path)
            expected = None if not head.noindex else (publish.ROBOTS_FOLLOW if head.follow else publish.ROBOTS_NOFOLLOW)
            assert out.robots == expected, path
            assert out.in_sitemap == (f"<loc>https://s.example{path}</loc>" in xml), path

    @pytest.mark.parametrize("over_the_bar", [False, True])
    def test_a_category_at_an_airport_agrees_with_its_head_and_the_sitemap(self, db, monkeypatch, over_the_bar):
        """The pairing page is the one address whose existence is a count rather than a row, so
        it is the one the badge could invent. Under the bar its head is the not-found head."""
        if over_the_bar:
            _pairing_pages_on(monkeypatch)
        db.get(Place, 1).indexed = True
        db.flush()
        head = seo.airport_category_head(db, "LHR", "whisky") or seo.NOT_FOUND_HEAD
        out = status(db, PAIR_PATH)
        assert (out.status == "missing") is not over_the_bar
        expected = None if not head.noindex else (publish.ROBOTS_FOLLOW if head.follow else publish.ROBOTS_NOFOLLOW)
        assert out.robots == expected, f"the badge says {out.robots!r}, the page sends {expected!r}"
        listed = f"<loc>https://s.example{PAIR_PATH}</loc>" in self._sitemap(db)
        assert out.in_sitemap == listed, f"the badge says in_sitemap={out.in_sitemap}, the sitemap says {listed}"

    def test_an_address_approved_by_name_is_listed_only_where_the_sitemap_lists_it(self, db, monkeypatch):
        """`in_sitemap` was derived from `INDEXED_PAGES` a second time instead of read from the
        list the sitemap builds, so it claimed an entry for `/how-we-choose`, which the file
        never emits, and for a category address that is named but has no page left to list."""
        thin = "/alcohol/gin"
        assert seo.category_head(db, "gin") is None, "the kit prices no gin, so that page is gone"
        monkeypatch.setattr(publish, "INDEXED_PAGES", frozenset({seo.METHOD_PATH, thin, "/alcohol/whisky"}))
        xml = self._sitemap(db)
        for path in (seo.METHOD_PATH, thin, "/alcohol/whisky"):
            out = status(db, path)
            assert out.robots is None and out.status == "indexable", path
            listed = f"<loc>https://s.example{path}</loc>" in xml
            assert out.in_sitemap == listed, f"{path}: the badge says {out.in_sitemap}, the sitemap says {listed}"
        assert status(db, "/alcohol/whisky").in_sitemap, "the one of the three the sitemap does emit"

    @pytest.mark.parametrize("approved", [False, True])
    def test_the_article_centre_agrees_with_its_own_head_and_the_sitemap(self, db, monkeypatch, approved):
        if approved:
            monkeypatch.setattr(publish, "INDEXED_PAGES", frozenset({seo.ARTICLES_PATH}))
        head = seo.articles_head(db)
        out = status(db, seo.ARTICLES_PATH)
        expected = None if not head.noindex else (publish.ROBOTS_FOLLOW if head.follow else publish.ROBOTS_NOFOLLOW)
        assert out.robots == expected
        assert out.in_sitemap == (f"<loc>https://s.example{seo.ARTICLES_PATH}</loc>" in self._sitemap(db))
        assert (out.status == "indexable") == (expected is None)


class TestTheBadgeIsNeverInTheCrawlerBody:
    def test_no_python_source_and_no_served_body_carries_the_mark(self):
        """A mark only the owner sees, rendered server-side, is cloaking: the crawler would be
        served a page no visitor gets. The SPA draws it after `/api/bw/me` answers, so the
        string belongs to `web/src` and to nothing under `app/`."""
        offenders = [
            str(f.relative_to(MAIN))
            for f in sorted((MAIN / "app").rglob("*.py"))
            if MARK in _code_only(f.read_text())
        ]
        assert not offenders, f"the badge's class reached a server-rendered body: {offenders}"
        assert "page-status" not in (MAIN / "web" / "index.html").read_text()
        drawn = (MAIN / "web" / "src" / "components" / "PageStatusBadge.tsx").read_text()
        assert 'import "./PageStatusBadge.css"' in drawn, "a stylesheet nothing imports fails silently"
        assert "caps.pages_status" in drawn and "enabled: caps.pages_status" in drawn


class TestTheBackfillThatGrantsALevelALaterPermission:
    def _level(self, db, permissions):
        from app.models import AccountLevel

        row = db.get(AccountLevel, "admin")
        if row is None:
            row = AccountLevel(name="admin", permissions=list(permissions), assignable=[])
            db.add(row)
        else:
            row.permissions = list(permissions)
        db.commit()
        return row

    def test_a_level_still_holding_the_previous_seed_is_granted_the_difference(self, db):
        from app.cli import backfill_level_permissions
        from app.services.accounts import PREVIOUS_SEED_PERMISSIONS, SEED_LEVELS

        from app.models import AccountLevel

        self._level(db, PREVIOUS_SEED_PERMISSIONS["admin"])
        message = backfill_level_permissions(db)
        assert "pages.status" in message, message
        assert set(db.get(AccountLevel, "admin").permissions) == set(SEED_LEVELS["admin"]["permissions"])

    def test_the_second_run_changes_nothing(self, db):
        from app.cli import backfill_level_permissions
        from app.services.accounts import SEED_LEVELS

        self._level(db, SEED_LEVELS["admin"]["permissions"])
        message = backfill_level_permissions(db)
        assert "granted none" in message and "already current" in message

    def test_a_level_edited_by_hand_is_named_and_left_alone(self, db):
        from app.cli import backfill_level_permissions
        from app.models import AccountLevel

        self._level(db, ["client.view"])
        message = backfill_level_permissions(db)
        assert "edited by hand" in message
        assert db.get(AccountLevel, "admin").permissions == ["client.view"]
