"""Python beside the unsplash recipe: the page a step is read in, and the search as a person left it.

**Unsplash is a JSON site behind Anubis, a proof-of-work check.** Every page it serves is
rendered from its own `/napi/`, and both sit behind the check. On 2026-09-22 a request that
named itself as the program it is got the data — the gate's default, which challenges what
says `Mozilla` and lets through what says who it is (LOG 635). **By 2026-10-28 `/napi/`
answered our name with the check too**: `401` and the check page on every address of it,
the search, a photo, a profile's photographs. The pages themselves still answer, and they
carry a list's first page in the same JSON — but only the first twenty, and they ignore
`?page=`, so a recipe reading them would bring 20 photographs of NASA's 118 and look
finished.

**So every step is read in a page of the program's browser** (`via="browser"`). The browser
opens the `/napi/` address the way the site's own pages do, passes the check by itself when
there is one — it is a computation a browser does, not a question a person answers — and the
engine reads the JSON it lands on (`follow.Document`). The cookies of the browser's own
session, which the steps carried on 2026-10-28, did not open `/napi/` to the plugin's client
in the owner's run (LOG 1095); why is not visible from outside the gate, and a page of the
browser needs no answer to it.

**The one door that stays shut is the site's own "Download" link**, `/photos/{id}/download`,
which counts the download for the author. The files do not need it: they live on the CDN,
`images.unsplash.com` and `plus.unsplash.com`, which answers anybody, and the site's own
terms for applications ask for exactly that — pictures taken from the CDN rather than copied
elsewhere.

Two things here are protocol, the one thing a gesture cannot compose
(`docs/specs/spec-parser-model.md` §10): **the page and the language a step is asked in**,
and **the search**, whose filters the site writes into its address under different words
than the API takes. Everything *after* the answer stays in the JSON — which fields hold the
pictures, what a file is called, when a listing ends.
"""

import json
import re
from urllib.parse import parse_qsl, quote, unquote_plus, urlsplit

from nd_crawler import SiteHandler

DOOR = "https://unsplash.com/napi"

#: The language of the address a person pasted, and every request of the chain is asked in it.
#:
#: **Not a nicety: the site searches in the language of its page.** `/es/s/fotos/luna`
#: shows the Spanish search for «luna»; asked without the language the API answers the
#: English one, 2 857 photographs of which 10 of the page's twenty, and asked with it 10 100
#: and 19 (measured 2026-09-22). The captions and the photo's page follow it too — «La
#: superficie de la luna…» and `/es/fotos/…` — so a file is called what the person read.
#: Said the way the site's own client says it, in `accept-language` — **and said for English
#: too**: a page of the browser asks in the language of the machine unless told otherwise
#: (`uk` on the owner's, measured 2026-10-28), and an English address on a German machine
#: would come back in German.
SPOKEN = re.compile(r"^https?://(?:www\.)?unsplash\.com/(de|es|fr|id|it|ja|ko|pt-br)/", re.IGNORECASE)

#: **Thirty is the ceiling of every list, and twenty of the search** — asked for fifty and a
#: hundred, the lists answer thirty and say so in `x-per-page`, the search answers twenty
#: (measured 2026-09-22). A page is asked for no more than it gives, because the next one
#: is asked by number: asking thirty of a twenty-item search would step over ten results on
#: every page and look complete.
PER_PAGE = 30
PER_SEARCH = 20

#: The site's search filters, in its address's words and in the API's.
#:
#: **Read off the site's own search code and checked against its pages** (2026-09-22): the
#: page for `?license=free` leaves out the Unsplash+ photograph that leads the unfiltered one,
#: `?orientation=square` changes the results where `squarish` — the API's word — does
#: nothing to the page. A closed table, because the address is somebody else's string and
#: passing its parameters through would let a crafted link ask the API anything.
FILTERS = {
    "license": ("plus", {"free": "none", "plus": "only"}),
    "orientation": ("orientation", {"landscape": "landscape", "portrait": "portrait", "square": "squarish"}),
    "order_by": ("order_by", {"latest": "latest", "curated": "editorial"}),
}


class Unsplash(SiteHandler):
    """One hook for every step: the same door asked something else."""

    def __init__(self) -> None:
        # Whether the run's journal has already said that Unsplash+ is being left out. Once
        # a run: a line per page would say the same thing a hundred times.
        self._said_plus = False

    def ask(self, step, engine):
        """The `/napi/` address this step is really about, read in a page of the browser."""
        address = self._address(step, engine)
        if address is None:
            return None
        return engine.request(address, headers=_spoken(step), via="browser")

    def _address(self, step, engine):
        """What to ask for, or `None` when the address is none of the shapes the nodes name.

        **A listing says what it lists in its payload** (`path`), so what is asked stays
        visible in the recipe; the page is the engine's own count. An opening step — the
        address a person pasted — names nothing, and then what the node caught out of the
        address decides. The order matters only where two shapes could catch at once, and
        here none do: each node's pattern names its own word.
        """
        kind = str(step.payload.get("kind") or "")
        listed = str(step.payload.get("path") or "")
        if kind in ("feed", "collections") and listed:
            return f"{DOOR}/{quote(listed, safe='/')}?page={step.page}&per_page={PER_PAGE}"

        def caught(name):
            value = engine.caught(name)
            return quote(str(value), safe="") if value else ""

        if photo := caught("photo"):
            return f"{DOOR}/photos/{photo}"
        if collection := caught("collection"):
            return f"{DOOR}/collections/{collection}"
        if topic := caught("topic"):
            return f"{DOOR}/topics/{topic}"
        if engine.caught("query"):
            return self._search(step, engine)
        if who := caught("who"):
            return f"{DOOR}/users/{who}"
        return None

    def _search(self, step, engine):
        """A page of the search the person pasted, with the filters they set.

        **The words in the address are the site's slug, not the query.** The site writes
        “night sky” as `/s/photos/night-sky` and asks its API for `night sky`: measured, the
        page's first three photographs are the API's first three for the words, and not for
        the hyphenated slug. So the hyphens go back to spaces, as the site's own code does.

        **Unsplash+ is left out here by the API, not by the nodes** — `plus=none` — unless the
        person turned it on or filtered for it themselves: a search they narrowed to Unsplash+
        is a search for exactly those photographs, and the nodes take whatever this returns.
        """
        words = unquote_plus(str(engine.caught("query"))).replace("-", " ").strip()
        asked = {"query": words, "page": str(step.page), "per_page": str(PER_SEARCH), **self.searched(step.url)}
        if "plus" not in asked and not engine.param("plus"):
            asked["plus"] = "none"
        found = "illustrations" if engine.caught("drawn") else "photos"
        return f"{DOOR}/search/{found}?" + "&".join(f"{name}={quote(value, safe='')}" for name, value in asked.items())

    @staticmethod
    def searched(url: str) -> dict[str, str]:
        """The filters in a search address, in the API's words. Unknown values are dropped:
        a filter the site would not recognise is not one the person set."""
        given = dict(parse_qsl(urlsplit(url).query, keep_blank_values=False))
        kept: dict[str, str] = {}
        for name, (asked, values) in FILTERS.items():
            value = values.get(given.get(name, "").lower())
            if value:
                kept[asked] = value
        return kept

    def read(self, step, answer, engine):
        """Say once a run that Unsplash+ photographs are being left out, and why.

        **The nodes leave them out silently, and silence is the wrong shape here.** A
        collection of Unsplash+ photographs would otherwise come back as a project with
        nothing in it and nothing in the journal — the most plausible failure there is. Without a
        subscription the site serves them under its watermark, so the recipe takes them only
        when asked to, and the journal says where the switch is.
        """
        if self._said_plus or engine.param("plus") or step.payload.get("kind") != "feed":
            return None
        try:
            listed = json.loads(answer.body)
        except ValueError:
            return None
        if isinstance(listed, list) and any(isinstance(one, dict) and one.get("premium") for one in listed):
            self._said_plus = True
            engine.log(
                "unsplash: Unsplash+ photos are left out - without a subscription the site "
                "serves them under its watermark; “Download Unsplash+ photos” in the project's settings takes them"
            )
        return None


def _spoken(step):
    """The language header for the address this chain began at — English when it names none."""
    found = SPOKEN.match(step.start or step.url)
    if found is None:
        return {"accept-language": "en"}
    language = found.group(1).lower()
    return {"accept-language": "pt-BR" if language == "pt-br" else language}
