"""IndexNow: tell the search engines which pages changed, instead of waiting.

One POST to api.indexnow.org carries up to ten thousand URLs and is shared by
every participating engine (Bing, Yandex, Seznam, Naver, Yep). The key proves
we own the host: it lives in the app's environment (`INDEXNOW_KEY`) and is
served back at `/<key>.txt`; nothing else about it is secret.

Only a page a person approved for indexing is ever submitted (plan W18, Stream K6;
`publish.is_indexed` is the rule): asking an engine to fetch a `noindex` page spends
the quota on a page it must then drop. What counts as changed comes from the
database, not from a list a collector keeps: an indexed product line changed when one
of its variants gained an observation, an indexed airport when any of its shops did,
and the sitemap whenever either happened. Approving a page pings it once
(`ping_approved`); nothing is pinged when a page merely crosses its candidate rule. So the
command can run after any collection with `--since` the run's start, or on a
schedule with `--hours`, and it never depends on a run having finished cleanly.
Every submission is logged with its count and status; no URL is ever invented.
"""

import json
import logging
import urllib.error
import urllib.request
from collections.abc import Callable
from datetime import datetime

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.models import Listing, Shop, PriceObservation, ProductVariant
from app.services import catalog_queries
from app.services.urls import airport_path, product_path

logger = logging.getLogger(__name__)

ENDPOINT = "https://api.indexnow.org/indexnow"
#: The protocol's ceiling per request.
BATCH = 10_000
TIMEOUT = 20

Poster = Callable[[str, bytes], int]


def changed_urls(db: Session, since: datetime, base: str, line_pages: bool = True) -> list[str]:
    """Absolute URLs of the INDEXED pages whose data changed after `since`, sitemap first,
    then airports, then product lines; each once, in a stable order."""
    from app.services import seo

    if not base:
        raise ValueError("IndexNow needs the site origin (PUBLIC_BASE_URL)")
    urls: list[str] = []
    changed_shops = (
        select(Listing.shop_id)
        .join(PriceObservation, PriceObservation.listing_id == Listing.id)
        .where(PriceObservation.observed_at > since)
        .distinct()
    )
    indexed_airports = seo.indexed_airport_codes(db)
    airports = [loc for loc in catalog_queries.airport_shops(db) if loc.iata in indexed_airports] if indexed_airports else []
    touched = {row for row in db.scalars(changed_shops)} if airports else set()
    airport_paths = sorted({
        airport_path(loc.iata, loc.city, loc.name)
        for loc in airports if loc.id in touched
    })
    if line_pages:
        product_paths = [path for path, _ in seo.indexed_line_rows(db, since)]
    else:  # LINE_PAGES off: the variant pages are the product pages again
        product_paths = [product_path(pid, name) for pid, name in db.execute(
            select(ProductVariant.id, ProductVariant.name)
            .join(Listing, Listing.variant_id == ProductVariant.id)
            .join(PriceObservation, PriceObservation.listing_id == Listing.id)
            .join(Shop, Shop.id == Listing.shop_id)
            .where(PriceObservation.observed_at > since, catalog_queries.publishable(db))
            .group_by(ProductVariant.id)
            .order_by(func.max(PriceObservation.observed_at).desc(), ProductVariant.id)
        ).all()]
    if airport_paths or product_paths:
        urls.append(f"{base}/sitemap.xml")
    urls.extend(base + path for path in airport_paths)
    urls.extend(base + path for path in product_paths)
    return urls


def ping_approved(paths: list[str], base: str, key: str, post: Poster | None = None) -> list[tuple[int, int]]:
    """The one ping an approval earns: the newly indexed pages and the sitemap that now lists
    them. Silent (an empty list) where the host has no key or no origin, so an approval on
    staging or the dev database never reaches the network; a failed ping never fails the
    approval, since the next `indexnow` run resubmits whatever changed."""
    if not paths or not base or not key:
        return []
    try:
        return submit([f"{base}/sitemap.xml", *[base + p for p in paths]], base, key, post or _post)
    except Exception:  # noqa: BLE001  the approval stands whatever the network does
        logger.exception("indexnow: the approval ping failed")
        return []


def _post(url: str, body: bytes) -> int:
    request = urllib.request.Request(
        url, data=body, method="POST",
        headers={"Content-Type": "application/json; charset=utf-8"},
    )
    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
            return response.status
    except urllib.error.HTTPError as exc:
        return exc.code


def submit(
    urls: list[str], base: str, key: str, post: Poster = _post, endpoint: str = ENDPOINT
) -> list[tuple[int, int]]:
    """POST the URLs in batches; returns (count, HTTP status) per batch.

    200 and 202 mean accepted; 400 is a malformed request, 403 a key the host
    does not serve, 422 URLs that do not belong to the host, 429 too many
    submissions. None of them is retried here: the next run resubmits whatever
    is still newer than its `since`.
    """
    if not key:
        raise ValueError("IndexNow key is not configured (INDEXNOW_KEY)")
    host = base.split("://", 1)[-1].rstrip("/")
    results: list[tuple[int, int]] = []
    for start in range(0, len(urls), BATCH):
        batch = urls[start:start + BATCH]
        payload = {
            "host": host,
            "key": key,
            "keyLocation": f"{base}/{key}.txt",
            "urlList": batch,
        }
        status = post(endpoint, json.dumps(payload).encode())
        logger.info("indexnow: %d urls -> HTTP %d", len(batch), status)
        results.append((len(batch), status))
    return results
