"""Python beside the 500px recipe: the GraphQL door, and nothing else.

**The whole site moved behind one endpoint in September 2026, and this file is why the
recipe still reads as a recipe.** The v1 REST API the recipe was written against answers
every address with `503` and "you'll need to update first" — a gate for old clients, not a
pause: the date it promised to be back had passed when it was measured (2026-09-18). The
new site is a single-page application that asks `api-neo.500px.com/graphql` for
everything, and that endpoint answers an anonymous plain client. A query in a POST body is
protocol knowledge, which no gesture composes and which reads worse as a string inside a
JSON field than as code (`docs/specs/spec-parser-model.md` §10). Everything *after* the
answer — which paths hold the photographs, what a file is called, which of a feed's items
are taken — stays in the JSON.

**Introspection is closed**, so none of this was written from a schema. The queries were
lifted out of the site's own bundle, where Apollo keeps every query it ships as a compiled
document, printed back to text and cut down to what the recipe reads. What a field is
called here is therefore what the site's own pages ask for.

**Every entity answers under the same alias whichever way it was addressed.** A gallery
opened by its old `/p/{who}/galleries/{slug}` address and one opened by `/gallery/{id}`
are two different root fields on the server and one `gallery` in the answer, so the
node that reads them does not have to know which it got.

**Signed in, the same questions get bigger pictures, and that is the other thing only
code can do here.** The server picks the size it signs into each picture's address by who
is asking: 1024 with the site's watermark for anybody, 2048 for a person signed in
(measured by the owner, 2026-09-18). How the site recognises that person is protocol too —
its cookies, plus one of them copied into a header of the same name, which is what the
page's own client does. So when the person signed in to 500px in our browser, every
question goes out under their session; when they did not, nothing changes.
"""

import re
import uuid

from nd_crawler import SiteHandler

DOOR = "https://api-neo.500px.com/graphql"

#: What a photograph is asked for, everywhere one is asked for.
#:
#: **`size_4k` and not the size we expect to get.** The server decides what each size field
#: holds for whoever is asking: an anonymous client gets the same 1024-pixel rendering,
#: marked with the site's watermark, in `size_1024`, `size_2048` and `size_4k` alike, and a
#: signed-in person gets 2048 (measured by the owner on their own account, 2026-09-18).
#: The size is signed into the address, so it cannot be chosen after the fact — asking for
#: the largest field is how the recipe takes whatever the site will give this caller, which
#: is also what the site's own viewer does (`size_4k || size_2048 || size_1024`).
#:
#: `uploadedAt` goes into the name, `takenAt` into the metadata — the same split the v1
#: recipe made with `created_at` and `taken_at`.
PHOTO = (
    "__typename id title description uploadedAt takenAt camera lens aperture shutterSpeed "
    "focalLength iso locationText viewCount likeCount uploader { username displayName } "
    "urls { size_4k }"
)

#: A video is a plain signed MP4 on `cdn-video-prod-com.500px.cloud` — measured, not a
#: playlist — so it is a file like any other.
VIDEO = (
    "__typename id title description uploadedAt durationMs locationText viewCount likeCount "
    "uploader { username displayName } videoUrl"
)

#: A post of several photographs. Only what a folder needs: its photographs are asked for
#: by a step of their own (`GROUP_PHOTOS`), because the type has no field that lists them.
GROUP = "__typename id title"

#: One item of a mixed list — a photographer's stream, a gallery, a feed.
MIXED = f"__typename ... on Photo {{ {PHOTO} }} ... on Video {{ {VIDEO} }} ... on PhotoGroup {{ {GROUP} }}"

# ---- what opens a subject --------------------------------------------------------

USER = (
    "query User($name: String!) { user: getUser(username: $name) "
    "{ id username displayName about publicResourceCount } }"
)
USER_BY_ID = (
    "query UserById($id: ID!) { user: getUserById(id: $id) "
    "{ id username displayName about publicResourceCount } }"
)

#: **The old address is answered by a lookup that returns the whole entity**, not only its
#: new id — measured on galleries, photographs and groups. So an address the site printed
#: before the move costs one request, the same as a new one.
GALLERY = (
    "query Gallery($slug: String!, $owner: String) { gallery: getGalleryByLegacyId("
    "legacyId: $slug, ownerUsername: $owner) { id name description itemCount } }"
)
GALLERY_BY_ID = (
    "query GalleryById($id: ID!) { gallery: getGalleryById(id: $id) "
    "{ id name description itemCount } }"
)

#: A "story" of the old site is a photo group of the new one: its address still resolves,
#: to a group with the same title and the photographer's own paragraphs.
STORY = (
    "query Story($slug: String!, $owner: String) { group: getPhotoGroupByLegacyId("
    "legacyId: $slug, ownerUsername: $owner) { id title description publicItemCount } }"
)
GROUP_BY_ID = (
    "query GroupById($id: ID!) { group: getPhotoGroupById(id: $id) "
    "{ id title description publicItemCount } }"
)

PHOTO_BY_LEGACY = f"query PhotoByLegacy($id: String!) {{ photo: getPhotoByLegacyId(legacyId: $id) {{ {PHOTO} }} }}"
PHOTO_BY_ID = f"query PhotoById($id: ID!) {{ photo: getPhotoById(id: $id) {{ {PHOTO} }} }}"
VIDEO_BY_ID = f"query VideoById($id: ID!) {{ video: getVideoById(id: $id) {{ {VIDEO} }} }}"

# ---- lists: the cursor threads, and the page size is not ours to choose -----------

# **Each list has its own ceiling, and asking past it loses half the list in silence.**
# Measured 2026-09-18: `pageResources` asked for 200 hands back 100 and puts `endCursor` at
# position 199; `leaderboardPage` asked for 100 hands back 50 and puts it at 99. The next
# page then starts where the cursor says, and the items between were never sent — a run
# that looks complete at half its size. So each list is asked for no more than it gives:
# 100 here, 50 on the leaderboard. Past 200 every list refuses by name.
RESOURCES = (
    "query Resources($id: ID!, $after: String) { feed: pageResources(userId: $id, first: 100, "
    f"after: $after) {{ totalCount edges {{ node {{ {MIXED} }} }} pageInfo {{ hasNextPage endCursor }} }} }}"
)
GALLERY_ITEMS = (
    "query GalleryItems($id: ID!, $after: String) { feed: pageGalleryItems(galleryId: $id, "
    f"first: 100, after: $after) {{ edges {{ node {{ {MIXED} }} }} pageInfo {{ hasNextPage endCursor }} }} }}"
)
GROUP_PHOTOS = f"query GroupPhotos($id: ID!) {{ photos: getPhotosByGroupId(groupId: $id) {{ {PHOTO} }} }}"

#: A pick can also be a whole gallery; the recipe takes photographs and groups from this
#: feed and leaves curated galleries alone (`recipes/README.md`).
EDITORS = (
    "query Editors($after: String) { feed: editorsChoices(first: 100, after: $after) { "
    "edges { node { resource { __typename ... on Photo { " + PHOTO + " } ... on PhotoGroup { "
    + GROUP + " } } } } pageInfo { hasNextPage endCursor } } }"
)

#: `qualityEnabled: true` is what the page itself sends unless the person switches
#: «Recommended Only» off, so it is what somebody pasting `/fresh` was looking at.
FRESH = (
    "query Fresh($after: String) { feed: freshResources(first: 100, after: $after, "
    "qualityEnabled: true) { edges { node { __typename ... on Photo { " + PHOTO
    + " } ... on PhotoGroup { " + GROUP + " } } } pageInfo { hasNextPage endCursor } } }"
)

#: `WEEKLY` is the board the page opens on; `/popular` is redirected here by the site.
LEADERBOARD = (
    "query Leaderboard($after: String) { feed: leaderboardPage(type: WEEKLY, first: 50, "
    "after: $after) { totalCount edges { node { photo { " + PHOTO + " } } } "
    "pageInfo { hasNextPage endCursor } } }"
)

#: The site's own words for its feeds, mapped to what answers them. `/editors` and
#: `/popular` are addresses the old site printed; the new one redirects them.
STREAMS = {
    "editors-picks": EDITORS,
    "editors": EDITORS,
    "fresh": FRESH,
    "leaderboard": LEADERBOARD,
    "popular": LEADERBOARD,
}

# ---- who answers what ------------------------------------------------------------

#: What each kind of step asks for, and which payload entry holds the id it asks about —
#: one entry, so the two cannot be paired wrongly (the lesson `imdb.py` paid for).
ASKING = {
    "resources": ("uid", RESOURCES),
    "gallery-items": ("gid", GALLERY_ITEMS),
    "group-photos": ("group", GROUP_PHOTOS),
}

#: The site's own test for an old photograph number (`/^\d+$/` in its router): an address
#: whose id is all digits was printed before the move and is looked up by that number.
LEGACY = re.compile(r"^\d+$")

#: The cookie the page copies into a header of the same name on every request — the site's
#: guard against another page speaking for the person. Read off its bundle, where the name
#: is built as `x-500px-csrf-token` plus the environment, `-prod`.
CSRF = "x-500px-csrf-token-prod"

#: Where the browser keeps the device id it sends. Used when there is one, so that a
#: signed-in session is asked about from the device it was opened on.
DEVICE = "x-500px-device-id-prod"


class Handler(SiteHandler):
    """One door. The rest of the recipe is declarative and shows in the constructor."""

    def __init__(self) -> None:
        # **Any UUID will do, and one per run is what a browser tab would send.** Without
        # the header the lookups by id answer `500` and name the header they miss; the
        # lists do not care. Lazy state on the handler is what the contract has it for
        # (`docs/specs/spec-parser-handler.md` §3.6), and losing it on a resume costs a
        # new random number.
        self._device = str(uuid.uuid4())

    def ask(self, step, engine):
        """Every step of this site is the same endpoint asked a different question."""
        asked = self._question(step, engine)
        if asked is None:
            # Not ours: an address on 500px.com that is none of the shapes the nodes name.
            return None
        query, variables = asked
        return engine.request(
            DOOR,
            method="POST",
            headers=self._headers(engine),
            body={"query": query, "variables": variables},
        )

    def _headers(self, engine):
        """What the page's own client sends: the device, and the person when there is one.

        **The cookies go out as the browser holds them**, all of them for this host, and
        not a guess at which one is the session: the name of that cookie was never seen
        from here — measuring it would take somebody's account — and a browser does not
        choose either. The engine asks the browser once a run, so this costs nothing on
        the steps after the first.
        """
        jar = {
            str(one.get("name")): str(one.get("value") or "")
            for one in engine.cookies(DOOR)
            if one.get("name")
        }
        headers = {"content-type": "application/json", "x-500px-device-id": jar.get(DEVICE) or self._device}
        if jar:
            headers["cookie"] = "; ".join(f"{name}={value}" for name, value in jar.items())
            if jar.get(CSRF):
                headers[CSRF] = jar[CSRF]
        return headers

    def _question(self, step, engine):
        """The query and its variables — from the payload when a step says what it wants,
        otherwise from whatever the opening node caught out of the address."""
        # `after` is `None` on the first page and not an empty string: the server refuses
        # a cursor it cannot align to a page, and an empty one is such a cursor.
        after = step.cursor or None
        kind = step.payload.get("kind", "")
        if kind in ASKING:
            name, query = ASKING[kind]
            found = step.payload.get(name)
            return (query, {"id": str(found), "after": after}) if found else None

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

        # **Order matters where two shapes catch the same name.** A gallery and a story
        # both catch the owner as `who`, and so does a profile; asking the profile first
        # would answer every gallery address with its owner's photostream.
        if shot := caught("shot"):
            return (PHOTO_BY_LEGACY if LEGACY.match(shot) else PHOTO_BY_ID), {"id": shot}
        if clip := caught("clip"):
            return VIDEO_BY_ID, {"id": clip}
        if story := caught("story"):
            return STORY, {"slug": story, "owner": caught("who") or None}
        if group := caught("group"):
            return GROUP_BY_ID, {"id": group}
        if gallery_id := caught("gallery_id"):
            return GALLERY_BY_ID, {"id": gallery_id}
        if gallery := caught("gallery"):
            return GALLERY, {"slug": gallery, "owner": caught("who") or None}
        if stream := caught("stream"):
            query = STREAMS.get(stream.lower())
            return (query, {"after": after}) if query else None
        if user_id := caught("user_id"):
            return USER_BY_ID, {"id": user_id}
        if who := caught("who"):
            return USER, {"name": who}
        return None
