"""Python beside the pexels recipe: the site's own key, the search as it was left, and which
file of a video to take.

**Pexels is a JSON site behind Cloudflare.** Every page it serves is rendered from its own
`/api/v3/`, and both answer only a client whose connection looks like a browser's: the
SDK's everyday client meets the wall, its second one — `curl_cffi` shaped like Chrome —
gets `200` (measured 2026-09-22). That is the reader's own business and happens without a
word from here; gallery-dl, asking with plain `requests`, stops at the wall.

**The API wants a key, and the key is the site's, not ours.** Without it every call answers
`401` and "Bad API credentials"; with the `secret-key` the site's own bundle (`_app-*.js`)
sends from every browser that opens a page, it answers. The same shape as Flickr's key
(owner's decision, 2026-08-30: the site's key rather than one of ours registered), and the
same value gallery-dl sends. **Written down rather than read off the bundle each run**, unlike
Flickr's: Flickr's page says its key holds for an hour, while this one is a constant of the
bundle, and reading it means a page and nine hundred kilobytes of script before every run. If
it changes, every call answers `401`, the engine names that status for each step, and the
`check` addresses of this recipe are what show it first.

**The files are announced here, one loop per answer, and not by the nodes.** A listing mixes
photos and videos, and a video is a list of renditions of which the right one is the largest
whose short side the project allows — the tier's ceiling, `video.resolution` — which a
declaration cannot compute. Taking the photos in the nodes and the videos here would announce
every page in two passes, all its photos before all its videos; the order of a page is the
order of its items, so both are announced in the one loop (the lesson of `reddit.py`,
`docs/LOG.md` 573). The nodes keep what is theirs: names, folders and paging.
"""

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

from nd_crawler import SiteHandler

#: The site, which its API lives under — after the language, when the address has one.
SITE = "https://www.pexels.com"

#: The language of the address a person pasted — `/de-de/` — and it goes into the API's path
#: the same way, for every request of the chain.
#:
#: **Not cosmetics: the site searches in the language of its page.** `/de-de/suche/mond/`
#: and the API asked for `mond` without the prefix share not one photo of the page's
#: twenty-four; with the prefix, twenty (measured 2026-09-22). Titles and descriptions follow
#: it as well — «Grüne Blätter» — so a file is called what the person read.
LOCALE = re.compile(r"^https?://(?:www\.)?pexels\.com/([a-z]{2}-[a-z]{2})/", re.IGNORECASE)

#: The key the site's own pages send with every call, as they send it.
KEYED = {
    "content-type": "application/json",
    "secret-key": "H2jk9uKnhRmL6WPwh89zBezWvr",
    "x-client-type": "react",
}

#: **A hundred a page for the lists, twenty-four for the search** — measured 2026-09-22: a
#: person's works and a collection hand back what they are asked for (two hundred of two
#: hundred), the search stops at twenty-four whatever it is asked. A page is asked by number,
#: so asking a search for more than it gives would step over the rest on every page.
PER_PAGE = 100
PER_SEARCH = 24

#: The search's own filters, passed on as the address has them — the site reads them off the
#: address under the same names its API takes. A closed list, because the address is
#: somebody else's string.
#:
#: **The list is the filter panel's, read off it on 2026-09-24** (docs/LOG.md 683). The first
#: version knew three of the ten, and a video search narrowed to 4K with nobody in it came down
#: whole: «moon» is 5 422 videos, and 529 with orientation, people, resolution and frame rate
#: set. Photos have no resolution, frame rate or duration, and the API ignores a filter the
#: kind has not got, so one list serves both.
FILTERS = (
    "orientation",
    "size",
    "color",
    "people_count",
    "people_age",
    "date_from",
    "resolution_name",
    "frame_rate",
    "min_duration",
    "max_duration",
)

#: The site's words for a work, where a page names one.
PHOTO = "photo"
VIDEO = "video"


class Pexels(SiteHandler):
    """One door asked many questions, and the files of every answer announced in order."""

    def ask(self, step, engine):
        """The API address this step is really about, asked with the site's key."""
        address = self._address(step, engine)
        if address is None:
            return None
        return engine.request(address, headers=KEYED)

    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`, the API's version included),
        so what is asked stays visible in the recipe; the page is the engine's own count.
        """
        door = f"{SITE}{_prefix(step)}/api"
        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}&seo_tags=true"

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

        if medium := caught("medium"):
            return f"{door}/v3/media/{medium}?seo_tags=true"
        if collection := caught("collection"):
            return f"{door}/v3/collections/{collection}"
        if engine.caught("query"):
            return self._search(step, engine, door)
        if who := caught("who"):
            return f"{door}/v3/users/{who}"
        return None

    def _search(self, step, engine, door):
        """A page of the search the person pasted, with the filters they set."""
        words = unquote_plus(str(engine.caught("query"))).strip()
        found = "videos" if engine.caught("videos") else "photos"
        given = dict(parse_qsl(urlsplit(step.url).query, keep_blank_values=False))
        asked = {
            "query": words,
            "page": str(step.page),
            "per_page": str(PER_SEARCH),
            "seo_tags": "true",
            **{name: given[name] for name in FILTERS if given.get(name)},
        }
        spelled = "&".join(f"{name}={quote(value, safe='')}" for name, value in asked.items())
        return f"{door}/v3/search/{found}?{spelled}"

    def read(self, step, answer, engine):
        """Announce the photos and videos of this answer, in its order; leave the rest to the nodes.

        Whatever the answer is about — a page of a list, a search, one work — its works are
        the items of `data` whose type is a photo or a video. A person or a collection is not
        a file, and the nodes read those.

        **The two switches are about lists that mix the two**: somebody's works, their
        highlights, a collection. The address of one work, and a search — which the site
        splits into photos and videos itself — say which kind they are, and bring it
        whichever way the switches stand.
        """
        try:
            said = json.loads(answer.body)
        except ValueError:
            return None
        found = said.get("data") if isinstance(said, dict) else None
        works = found if isinstance(found, list) else [found]
        mixed = step.payload.get("kind") == "feed"
        wanted = {
            PHOTO: not mixed or bool(engine.param("photos")),
            VIDEO: not mixed or bool(engine.param("videos")),
        }
        ceiling = _ceiling(engine.param("quality"))
        prefix = _prefix(step)
        for one in works:
            if isinstance(one, dict) and wanted.get(str(one.get("type"))):
                _announce(one, ceiling, prefix, engine)
        return None


def _announce(work, ceiling, prefix, engine):
    """One work as the file it is: the photo in full size, or the video at the project's ceiling."""
    facts = work.get("attributes") or {}
    kind = work.get("type")
    ident = str(facts.get("id") or work.get("id") or "")
    user = facts.get("user") or {}
    author = " ".join(" ".join(str(user.get(part) or "").split()) for part in ("first_name", "last_name")).strip()
    title = str(facts.get("title") or "").strip()
    day = str(facts.get("created_at") or "")[:10]
    slug = str(facts.get("slug") or "").strip()
    # In the language of the address; the site answers `/de-de/photo/…` with its own word for
    # it, `/de-de/foto/…`, so the English word is enough to be sent to the right page.
    page = f"{SITE}{prefix}/{kind}/{slug + '-' if slug else ''}{ident}/"
    meta = {"author": author, "license": facts.get("license")}
    if kind == PHOTO:
        address = (facts.get("image") or {}).get("download_link")
        expect = "image/"
    else:
        video = facts.get("video") or {}
        chosen = _rendition(video.get("video_files") or (), ceiling)
        address = chosen.get("link") if chosen else None
        expect = "video/"
        meta["duration"] = facts.get("duration")
        meta["fps"] = chosen.get("fps") if chosen else None
    if not address:
        return
    engine.download(
        str(address),
        name=f"{author} [{day}] {title} {ident}",
        description=str(facts.get("description") or ""),
        page=page,
        expect=expect,
        meta={key: value for key, value in meta.items() if value not in (None, "")},
    )


def _rendition(files, ceiling):
    """The largest rendition whose short side the project allows — or, when every one is taller,
    the smallest there is: a person asking for small wants the smallest, not nothing.

    **The short side, because that is what `1080p` means**: a vertical video of 1080 × 1920 is
    1080p, and read as a height it would never fit under the ceiling it was made for
    (`docs/specs/spec-params.md` §4.6)."""
    sized = [one for one in files if isinstance(one, dict) and all(one.get(key) for key in ("link", "width", "height"))]
    if not sized:
        return None

    def side(one):
        return min(int(one["width"]), int(one["height"]))

    allowed = [one for one in sized if ceiling is None or side(one) <= ceiling]
    return max(allowed, key=side) if allowed else min(sized, key=side)


def _ceiling(value):
    """The project's answer to "quality" as a number of pixels, or `None` for no ceiling."""
    try:
        return int(str(value))
    except (TypeError, ValueError):
        return None


def _prefix(step):
    """`/de-de` for an address in German, and nothing for one in English."""
    found = LOCALE.match(step.start or step.url)
    return f"/{found.group(1).lower()}" if found else ""
