"""Python beside the flickr recipe: the key that opens the API, and the search as a
person left it.

**Flickr is a JSON site wearing an HTML coat.** Every page it serves — a photostream, an
album, a group pool, a search — is rendered from `api.flickr.com/services/rest`, and that
endpoint answers an anonymous caller: one `GET`, five hundred photographs, and with them
the whole ladder of sizes, the author, the date taken, the description and the tags. So
this recipe never reads a page of the site at all; it asks the same door a different
question per step, which is the shape `imdb.py` already has.

Three things here, and the first two are protocol — the one thing a gesture cannot compose
(`docs/specs/spec-parser-model.md` §10):

**1. The key, which is not ours and is not in this file.** The API wants an `api_key`, and
Flickr puts *its own* into every page it serves (`.flickr.api.site_key = "…"`). The same
page's config says `site_key_fetch_interval: 3600`, so the site treats that value as
hourly — which is exactly why it cannot be written down here: a constant in this file is a
recipe that dies quietly an hour after somebody rotates it. gallery-dl reads it the same
way from the same marker, which is a second opinion on the method rather than a copy of
one. Owner's decision, 2026-08-30: the site's key, not one of ours registered.

**2. The search, with the filters the person set.** The search dialog hands back the
address they stopped on — `?text=vintage+lighthouse&license=4,5&orientation=landscape` —
and those names are, measured, the *same* names `flickr.photos.search` takes. Carrying
them over is a dozen optional parameters; a `when` pattern would need a named group per
filter and all of them kept in step. Dropping them instead would be worse than untidy: a
person who narrowed to landscape and a free licence would get back everything, which looks
exactly like a search that worked.

**3. Videos, which are the one thing on this site that is not at an address you can build.**
A listing hands back photographs and films in one list, and a film's `url_k` is its preview
frame — an ordinary picture at an ordinary address, so taking the listing whole gives twenty
pictures where twenty films were asked for, and nothing anywhere reports a failure. The
recipe therefore splits the list by `media` (`only`, `spec-parser-rules.md` §6.5): pictures
walk the ladder of sizes, films go to `video.getStreamInfo`.

That call answers an anonymous caller like every other, but only with the photograph's
`secret` — without it, `{"stat": "fail", "code": 1, "Photo not found"}`. Every listing here
already carries `secret`, so the film costs one call and not two; gallery-dl fetches
`photos.getInfo` first because it cannot always assume one.

**What comes back is signed and perishable.** The address carries `?s=`, whose payload names
an expiry half an hour out, and without it the CDN answers `403`. Half an hour is generous
for twenty films and is not generous for a photostream of a hundred and fifty: between the
step that asks for the address and the transfer that uses it stands the queue.

Everything *after* the answer stays in the JSON — which paths hold the photographs, which
size to prefer, what a file is called, how the cursor threads the next page.
"""

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

from nd_crawler import SiteHandler

DOOR = "https://api.flickr.com/services/rest"

#: The one method a listing step may reach without naming it: a search has no payload of
#: its own because its parameters are the person's filters, and those live in the address.
SEARCH = "flickr.photos.search"

#: Where the key is read from, and how it is spelled there. Measured on nine pages, from
#: `/explore` to a single photograph, and this one is the smallest that carries it.
#:
#: **Not every page does, though — the front page does not** (measured 2026-08-31: 57 KB
#: and no marker, against 654 KB here). So a fallback page has to be one that was checked,
#: not any address on the host.
KEYED = "https://www.flickr.com/photos/"
SITE_KEY = re.compile(r'\.flickr\.api\.site_key\s*=\s*"([0-9a-zA-Z]+)"')

#: Enough to be answered: measured 2026-08-30, neither a cookie nor an `origin` nor a token
#: is wanted. The referer is sent because the key belongs to the site and the request is
#: made in its name.
POLITE = {"referer": "https://www.flickr.com/"}

#: What a listing must bring back, and the reason this recipe needs one request where ND5
#: needed one per photograph.
#:
#: **Every rung, best first, because the transfer can now step down.** `live.staticflickr.com`
#: keeps the sizes the site's own pages display ready to hand and makes the rest — the
#: original, `3k` through `6k` — when they are asked for. Ordinarily it makes them without
#: complaint; on 2026-09-09 it spent a morning refusing to, answering `429` with an empty
#: body and no `Retry-After` to every address that was not already on the edge, while the
#: same photograph's `k` and below answered `200` back to back. By afternoon the same cold
#: addresses came back `200` with `x-cache: Miss from cloudfront`, so the wall was a state
#: of the site rather than a policy of it (`docs/LOG.md` 494, 495).
#:
#: **That is precisely why the ladder is written out rather than trimmed to what is safe.**
#: A refused address falls to the next one (`spec-plugin-transfer.md` §9.1), so a morning
#: like that costs a few smaller files instead of the run; and on an ordinary day the first
#: rung answers and nothing below it is ever asked for. Trimming was the earlier fix and it
#: paid for one bad morning with every photograph for ever.
#:
#: **`url_o` is first and it is not always there.** Flickr omits it when the photographer
#: has not allowed downloads — the album this was found on has no original for any of its
#: 524 photographs — so it is a rung like any other: offered or not, taken if offered,
#: stepped over if refused.
EXTRAS = ",".join(
    (
        "url_o", "url_6k", "url_5k", "url_4k", "url_3k", "url_k", "url_h",
        "url_l", "url_c", "url_z", "url_m", "url_n", "url_w",
        "description", "date_taken", "date_upload", "owner_name", "realname",
        "tags", "views", "media", "license", "geo",
    )
)

#: The ceiling the site itself declares and the size of one page.
#:
#: **Five hundred is the measured maximum**: asked for 500, received 500. A photostream of
#: 1462 is three requests where ND5 made 1462, because it fetched each photograph's page to
#: learn its address.
PER_PAGE = 500

#: What an opening step asks for. It exists to have an answer to read a name out of — the
#: walk proper starts on the step after it — so it asks for the smallest listing there is.
PEEK = 1

#: The code Flickr answers with when a listing has a ceiling and the walk has reached it:
#: `{"stat": "fail", "code": 6, "message": "Pagination limit reached"}`.
#:
#: **The end of a listing, not a fault** — and the only reason it needs naming is that this
#: site says both with the same shape. Measured 2026-08-30 on a group pool of 26 220
#: photographs: pages 1 to 20 answer with five hundred each and page 21 refuses, so the real
#: ceiling is 10 000 while `pages` in the same answers says 53. A run that treated this as a
#: failure would end every large group crawl on a red step having taken everything there was.
PAGINATION_LIMIT = 6


class Flickr(SiteHandler):
    """One hook, one field. Every step of this site is the same door asked something else.

    The field is the key, fetched once and held for the run — the shape `reddit.py` uses
    for its bearer, and for the same reason: a value in the payload would ride in every
    queue row, show in the run panel's name for each step and be written to the database
    beside them.
    """

    def __init__(self) -> None:
        self._key = ""

    def ask(self, step, engine):
        """Build the API call this step is really about.

        **The payload names the call when the recipe knows it**, which is every step the
        walk queues for itself; an opening step — the address a person pasted — names
        nothing, and then what the node's condition caught out of the address decides.
        A step that is neither is not ours, and `None` says so honestly: the nodes get
        their turn, and for this recipe that means nobody claims it.
        """
        asked = self._call_for(step, engine)
        if asked is None:
            return None
        method, params = asked
        query = {
            "method": method,
            "api_key": self._keyed(engine),
            "format": "json",
            "nojsoncallback": "1",
            **params,
        }
        return engine.request(DOOR + "?" + _query(query), headers=POLITE)

    # ---- which call ---------------------------------------------------------------

    def _call_for(self, step, engine):
        """`(method, params)` for this step, or `None` when the address is not ours.

        **A listing says so in its payload, and that is what separates the two halves of
        this handler.** A step the walk queued for itself asks for five hundred; an opening
        step — the address a person pasted — asks for one, because all it is there for is a
        name to call the project.

        **Listing steps carry their own method in the payload**, so what is asked stays
        visible in the recipe rather than being decided here. The one exception is a
        search, whose parameters are the person's own filters and live in the address; a
        payload would have to carry a dozen optional names and keep them in step.
        """
        kind = str(step.payload.get("kind") or "")
        method = str(step.payload.get("api") or "")
        if not method and kind != "found":
            return self._opening(step, engine)
        if method:
            # **An underscore means the value is ours, not the site's.** A payload carries
            # two kinds of thing — parameters of the call, and what the recipe is telling
            # its own next step — and everything here is turned into a query, so the two
            # have to be told apart by shape. A list of exceptions would grow silently
            # wrong the first time somebody adds a third.
            params = {
                key: str(value)
                for key, value in step.payload.items()
                if key not in ("api", "kind") and not key.startswith("_")
            }
        else:
            method, params = SEARCH, self.searched(step.url)
        if kind in ("feed", "found"):
            # Only a listing takes these. A call about one photograph — its sizes — is not
            # a page of anything, and `extras` on it would be a parameter the method does
            # not know.
            params["per_page"] = str(PER_PAGE)
            params["page"] = str(step.cursor or 1)
            params["extras"] = EXTRAS
        return method, params

    def _opening(self, step, engine):
        """The first call for an address somebody pasted.

        **Each shape needs a different thing first**, and only two of them need a lookup at
        all: a photostream and a group are addressed by a name the API does not take —
        `flickr.com/photos/wozzeck47` — and have to be turned into an id before anything
        can be asked about them. The rest are already addressed by what the API wants, so
        their opening call *is* their listing, asked for a single photograph: the step
        exists to give the node a body to read a name out of, and the walk proper begins on
        the page after it.
        """
        caught = {name: engine.caught(name) for name in ("alias", "album", "photo", "tag", "found")}
        if caught["photo"]:
            return "flickr.photos.getInfo", {"photo_id": str(caught["photo"])}
        if caught["album"]:
            return "flickr.photosets.getPhotos", _peek({"photoset_id": str(caught["album"])})
        if caught["tag"]:
            return "flickr.photos.search", _peek({"tags": str(caught["tag"])})
        if caught["alias"]:
            # A photostream, its owner's favourites, or a group — all three addressed by a
            # name. The node caught which, and the lookup answers with the id everything
            # else on this site is asked in.
            return "flickr.urls.lookupUser", {"url": step.url}
        if engine.caught("group"):
            return "flickr.urls.lookupGroup", {"url": step.url}
        if caught["found"] is not None:
            return "flickr.photos.search", _peek(self.searched(step.url))
        if engine.caught("explore"):
            return "flickr.interestingness.getList", _peek({})
        return None

    # ---- the person's own search --------------------------------------------------

    @staticmethod
    def searched(url: str) -> dict[str, str]:
        """The filters a person set on the site, in the API's own words.

        **Measured rather than mapped** (2026-08-30): the site writes `text`, `license`,
        `orientation`, `sort`, `media`, `safe_search`, `min_taken_date` and the rest into
        the address under the names `flickr.photos.search` already takes, and every one of
        them was checked to actually narrow the answer — `orientation=landscape` returned
        100 landscape photographs out of 100, `license=4,5,6,9,10` returned none of the
        `0`-licensed ones that fill an unfiltered page.

        **A closed list, because an open one would be a way to hand the API anything.** The
        address comes off a page the person navigated, so it is somebody else's string;
        passing every parameter through would let a crafted link reach parts of the API
        this recipe has no business asking about.
        """
        allowed = (
            "text", "tags", "tag_mode", "license", "sort", "media", "safe_search",
            "content_type", "orientation", "color_codes", "styles", "in_gallery",
            "min_taken_date", "max_taken_date", "min_upload_date", "max_upload_date",
            "dimension_search_mode", "height", "width", "user_id", "group_id",
        )
        query = dict(parse_qsl(urlsplit(url).query, keep_blank_values=False))
        kept = {name: query[name] for name in allowed if query.get(name)}
        if "sort" not in kept:
            # **The one place the two disagree, and it is the whole point of this dialog.**
            # The site ranks a search by relevance; the API, asked without a `sort`, ranks
            # it by date — so «tiger» comes back as a hoverfly called Tiger and a cat called
            # Little tiger, newest first (owner, 2026-08-30, first run through the dialog).
            # The person pressed download on a page of tigers, so the answer has to be that
            # page. A search with nothing to be relevant *to* is the exception: there the
            # site has nothing to rank either, and interestingness beats an arbitrary order.
            asked = any(name in kept for name in ("text", "tags"))
            kept["sort"] = "relevance" if asked else "interestingness-desc"
        return kept

    # ---- the key ------------------------------------------------------------------

    def _keyed(self, engine) -> str:
        """The site's own API key, read off the site's own page. Once per run.

        **Loud when it is not there.** Without a key nothing else in this run can work, and
        a request sent without one comes back as `{"stat": "fail"}` on every step — a body
        the nodes read as an empty gallery. Saying it here, once, beats a run that reports
        no files and no fault (`spec-parser-model.md` §7).
        """
        if self._key:
            return self._key
        answer = engine.http.fetch(KEYED)
        found = SITE_KEY.search(answer.body.decode("utf-8", "replace"))
        if not found:
            raise RuntimeError(
                f"flickr.py: no api key on {KEYED} ({answer.status}). The site marks it "
                f'`.flickr.api.site_key = "…"`, and without it the API answers nothing'
            )
        self._key = found.group(1)
        return self._key

    # ---- the answer is either data or a refusal -----------------------------------

    def read(self, step, answer, engine):
        """Refuse loudly on `{"stat": "fail"}` instead of reading it as an empty page.

        **This site says no with a `200`.** An expired key, an id that is not there, a
        method asked wrongly — all of them arrive as a perfectly good response whose body
        says `{"stat": "fail", "code": 1, "message": "User not found"}`. Left to the nodes,
        every one of those is a listing with no photographs in it, which is the most
        plausible kind of failure there is (`create-recipe`: «the worst kind of failure is a plausible one»).
        """
        try:
            said = json.loads(answer.body)
        except ValueError:
            # Not JSON at all: this recipe never asks for anything else, so it is either the
            # API in trouble or a step nobody built a call for. The nodes will find nothing
            # and say so; there is nothing here worth adding.
            return None
        if isinstance(said, dict) and said.get("stat") == "fail":
            if said.get("code") == PAGINATION_LIMIT:
                # Handled, and nothing came of it: the nodes do not run, so no file is
                # claimed and no next page is queued. The walk ends here because the site
                # says there is no here after this one.
                engine.log(f"flickr: {said.get('message') or 'pagination limit'}. The listing ends here")
                return True
            raise RuntimeError(
                f"flickr.py: the API refused this step: {said.get('message') or said!r} "
                f"(code {said.get('code')})"
            )
        return None


def _peek(params: dict[str, str]) -> dict[str, str]:
    """One photograph: an opening step reads a name, not a gallery."""
    return {**params, "per_page": str(PEEK), "extras": EXTRAS}


def _query(params: dict[str, str]) -> str:
    """The query string, escaped once and by us.

    Written out rather than `urlencode` for one reason: the values here come from three
    places — our own constants, ids the site handed back, and a person's search terms —
    and one of the three is somebody else's string.
    """
    from urllib.parse import quote

    return "&".join(f"{quote(str(name), safe='')}={quote(str(value), safe='')}" for name, value in params.items())
