"""Python beside the reddit recipe: the address a step really asks, the session it asks
under, and the pictures a post keeps where the declaration cannot reach.

**The site's own JSON, under the program's browser session** (measured 2026-09-19). Every
page of Reddit has a `.json` twin — a listing, a profile, a post — and it answers a plain
client exactly when that client carries the cookies the browser got by opening the site:
`200` and the same data the API gives, a hundred posts a call. Without them it is `403` and
a block page. The API proper answers only a registered application, and the default one
the recipe used to ship was gallery-dl's, whose thousand requests a window were shared by
everybody who uses it and were gone two and a half minutes into each ten. The session's
window is its own: a hundred requests every ten minutes.

**Where the session comes from is the recipe's `session` and `ask`, not this file.** Before
a run the engine asks the site one small address with the browser's cookies; only if the
site refuses does it put the page in front of the person, and loading it gives the browser
a fresh session (`spec-parser-rules.md` §2.4). The cookie that opens the JSON, `loid`,
lives 400 days from the first visit and is signed, which is why the site is asked rather
than the jar.

What stays in the JSON shows in the constructor: which paths hold the posts, the paging, the
project's name, what a profile gives besides its posts, and the catch-all that rewrites
`preview.redd.it` into `i.redd.it`. What is here besides the address is `read`, and it
takes every picture of a post: a gallery's pictures live in a dictionary per post, the
format repeats over one list and not over a list inside each of its items, and the files of
a page have to come in the order of its posts — which one loop keeps and two passes do not.
"""

import datetime as dt
import json
from urllib.parse import parse_qs, quote, urlencode, urlsplit

from nd_crawler import SiteHandler
from nd_sdk.transfer.naming import MAX_NAME

SITE = "https://www.reddit.com"

#: The orders a listing can be asked in, as the site's own addresses spell them. A subreddit
#: puts it in the path — `/r/formula1/top` — and a profile in the query — `?sort=top`.
#: **The order changes what arrives, not only how it is arranged**: `top` for a week and the
#: default order of the same subreddit shared not one post of twenty-five (2026-09-19). A word
#: that is not an order — `/r/formula1/wiki` — leaves the default below.
SORTS = frozenset({"hot", "new", "top", "rising", "controversial"})

#: The order when the address names none: the newest first (owner, 2026-09-19). Not the
#: site's own default, `hot`: a run that one day takes only the first fifty of a listing must
#: mean the latest fifty, and `hot` is a choice a person makes by writing it into the address.
#: Asked for by name for a profile's posts too: without it the site answers those `hot` as
#: well — measured, the list with no `sort` matched `sort=hot` and parted from `sort=new`
#: where two posts shared a day.
DEFAULT_ORDER = "new"

#: How far back `top` and `controversial` reach, from the address's `?t=`.
SPANS = frozenset({"hour", "day", "week", "month", "year", "all"})

#: Posts per call: the most the site gives, measured — asked for a hundred, it sends a hundred.
PAGE = 100

#: The kinds of media a post's `media_metadata` holds that are pictures. `RedditVideo` sits
#: in the same dictionary and belongs to Video Downloader.
PICTURES = frozenset({"Image", "AnimatedImage"})

#: The room a file's extension takes at the end of its name, dot included: `.jpeg` and
#: `.webp` are the longest `i.redd.it` gives. Measured over 432 files of 2026-09-19 —
#: `.jpg`, `.jpeg`, `.png` and `.gif`, every one on `i.redd.it`.
EXTENSION = len(".jpeg")


class Reddit(SiteHandler):
    """Two hooks: the address a step asks under the session, and the pictures of a post."""

    def ask(self, step, engine):
        """Every address of this site is its own `.json`, asked under the browser's session.

        **The address in the queue is the human one** — `reddit.com/r/formula1` is what a
        person pasted — and what answers is its `.json` twin. Which one comes from the
        step's payload when the walk queued the step itself, and otherwise from what the
        node's condition caught out of the address.
        """
        where = self._where(step, engine)
        if where is None:
            return None
        return engine.request(where, headers=_session(engine))

    def read(self, step, answer, engine):
        """Every picture of every post, post by post, then whatever the nodes say.

        **A gallery is a dictionary per post**, and the format repeats over one list, not
        over a list inside each of its items; so each picture of a gallery used to arrive
        through the catch-all that rewrites every preview address in the answer — named by
        its random id, with the listing for a source page and no author or date — and a GIF
        inside a gallery, whose address is not a preview at all, did not arrive. Measured on
        three answers of 2026-09-19: everything the catch-all found is covered by a post's
        own `url`, its `media_metadata` and a crosspost's parent.

        **Singles are here too, because a page's files come in the order of its posts.**
        With the singles in the declaration and the galleries here, the same page was
        announced in two passes, and every gallery on it came before every single picture
        (2026-09-19, the owner's first run on a gallery-heavy subreddit). One loop over the
        posts is the only thing that keeps their order.

        **First, and that is what keeps the catch-all harmless.** A file is announced once
        and the first announcement wins, so the named picture stands and the catch-all's
        copy of it is dropped; the catch-all stays for whatever a post may carry that no
        answer has shown yet.
        """
        for post in _posts(answer.body):
            meta = _meta(post)
            page = f"{SITE}{post.get('permalink') or ''}"
            single = _single(post)
            if single:
                name, cut = _named(post, meta)
                engine.download(single, name=name, page=page, description=cut, meta=meta, expect="image/")
            for number, (url, caption) in enumerate(_pictures(post), start=1):
                name, cut = _named(post, meta, number)
                engine.download(
                    url,
                    name=name,
                    page=page,
                    description="\n\n".join(text for text in (cut, caption) if text),
                    meta=meta,
                    expect="image/",
                )
        engine.declared()
        return True

    def _where(self, step, engine) -> str | None:
        """The `.json` this step is about, or `None` when the step is not ours.

        **A profile is two calls, and the first is the cheap one.** `about.json` names the
        person and carries their avatar and banner, which is what the wizard's probe needs
        and all it asks for; the posts come after, from the step it queues with
        `kind: posts`, a hundred at a time.
        """
        if step.payload.get("kind") == "posts":
            user = quote(str(step.payload.get("user") or ""), safe="")
            asked = _from_address(step.url)
            query = _listing(step.cursor, sort=asked["sort"] or DEFAULT_ORDER, span=asked["t"])
            return f"{SITE}/user/{user}/submitted/.json?{query}"
        post = engine.caught("post") or ""
        if post:
            return f"{SITE}/api/info.json?raw_json=1&id=t3_{post}"
        user = engine.caught("user") or ""
        if user:
            return f"{SITE}/user/{quote(str(user), safe='')}/about.json?raw_json=1"
        sub = engine.caught("sub") or ""
        if sub:
            said = str(engine.caught("sort") or "")
            order = said if said in SORTS else DEFAULT_ORDER
            span = _from_address(step.url)["t"] if order in ("top", "controversial") else ""
            return f"{SITE}/r/{quote(str(sub), safe='')}/{order}/.json?{_listing(step.cursor, span=span)}"
        return None


def _session(engine) -> dict[str, str]:
    """The browser's cookies for the site, as the header a step goes out with.

    **All of them, as the browser holds them**, not the one known to open the JSON: which
    cookie the site reads is the site's business and may change, and a browser does not
    choose either. None at all is a valid answer — the step then meets the site's `403`,
    which is what a person who turned the page off entirely has chosen.
    """
    jar = [one for one in engine.cookies(f"{SITE}/") if one.get("name")]
    if not jar:
        return {}
    return {"cookie": "; ".join(f"{one['name']}={one.get('value') or ''}" for one in jar)}


def _posts(body) -> list[dict]:
    """The posts of an answer, whichever of the site's listings it is; none when it is not one."""
    try:
        held = json.loads(body)
    except ValueError:
        return []
    data = held.get("data") if isinstance(held, dict) else None
    children = data.get("children") if isinstance(data, dict) else None
    if not isinstance(children, list):
        return []
    return [child["data"] for child in children if isinstance(child, dict) and isinstance(child.get("data"), dict)]


def _single(post) -> str:
    """The one picture a post *is*, when it is one: hosted by the site itself and not a video.

    A gallery's address is the post (`reddit.com/gallery/…`) and a link post's is somebody
    else's site; both are `is_reddit_media_domain: false` and have nothing to offer here.
    """
    if not post.get("is_reddit_media_domain") or post.get("is_video"):
        return ""
    return str(post.get("url") or "")


def _pictures(post) -> list[tuple[str, str]]:
    """Every picture a post keeps in `media_metadata`, in the gallery's order, with its caption.

    **A crosspost keeps its pictures on the parent** it shares; its own dictionary is empty.
    A picture's full size is the preview's file on `i.redd.it` — the rewrite the recipe has
    always made for previews — and a GIF's is its `gif` address, which is already there.
    """
    held = post
    if not post.get("media_metadata") and post.get("crosspost_parent_list"):
        held = post["crosspost_parent_list"][0] or {}
    metadata = held.get("media_metadata") or {}
    if not isinstance(metadata, dict):
        return []
    items = (held.get("gallery_data") or {}).get("items") or []
    order = [str(item.get("media_id")) for item in items if item.get("media_id")] or list(metadata)
    captions = {str(item.get("media_id")): str(item.get("caption") or "") for item in items}
    found: list[tuple[str, str]] = []
    for media_id in order:
        media = metadata.get(media_id) or {}
        if media.get("status") != "valid" or media.get("e") not in PICTURES:
            continue
        largest = media.get("s") or {}
        if media.get("e") == "AnimatedImage":
            url = str(largest.get("gif") or "")
        else:
            preview = urlsplit(str(largest.get("u") or ""))
            url = f"https://i.redd.it{preview.path}" if preview.path else ""
        if url:
            found.append((url, captions.get(media_id, "")))
    return found


def _named(post, meta: dict[str, str], number: int = 0) -> tuple[str, str]:
    """The file's name, and the post's whole title when the name had no room for all of it.

    **Author, day, title, the post's id** — and a gallery picture's number after it — the
    way Flickr and 500px name theirs (owner, 2026-09-19): `nasa [2026-09-18] Artemis II in
    pictures 1p1iw69 2`. A folder sorted by name then reads by author and by day, and the
    id keeps apart two posts one author titled alike. The day is the date part of the
    `posted` beside it, so the name and the value cannot disagree; UTC, like `|date`.

    **The title is cut here, because the engine cuts the end.** A name is capped at the
    SDK's length by dropping its tail, and the tail is the id and the number: measured on
    NASA's 919 posts, whose median title is 106 characters, the full form lost the tail on
    173 files of 380 and the number on 54 pictures of 144 — pictures of one gallery then
    share a name and fall apart into `~` suffixes, out of order. So the title gives way,
    at a word when the last word is not half of it, and the whole title goes into the
    description, which exists for text a name has no room for.
    """
    author = str(post.get("author") or "")
    day = f"[{meta['posted'][:10]}]" if meta.get("posted") else ""
    tail = " ".join(part for part in (str(post.get("id") or ""), str(number) if number else "") if part)
    title = " ".join(str(post.get("title") or "").split())
    # Three spaces join the four parts.
    room = MAX_NAME - EXTENSION - len(author) - len(day) - len(tail) - 3
    kept = title
    if len(title) > room:
        kept = title[: max(room, 0)]
        space = kept.rfind(" ")
        kept = (kept[:space] if space > room // 2 else kept).rstrip()
    name = " ".join(part for part in (author, day, kept, tail) if part)
    return name, (title if kept != title else "")


def _meta(post) -> dict[str, str]:
    """What the declarative download of a post says about it, said the same way."""
    meta = {
        "author": str(post.get("author") or ""),
        "subreddit": str(post.get("subreddit_name_prefixed") or ""),
        "score": str(post.get("score") if post.get("score") is not None else ""),
    }
    created = post.get("created_utc")
    if isinstance(created, (int, float)):
        meta["posted"] = dt.datetime.fromtimestamp(created, dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
    return {key: value for key, value in meta.items() if value}


def _from_address(url: str) -> dict[str, str]:
    """The order and the span a person chose, read from the address they pasted.

    Only the words the site uses for them survive: anything else in the query is the site's
    own tracking or a filter this recipe does not take, and passing it on would hand the
    site whatever the address happened to carry.
    """
    query = parse_qs(urlsplit(url or "").query)
    sort = (query.get("sort") or [""])[0].lower()
    span = (query.get("t") or [""])[0].lower()
    return {"sort": sort if sort in SORTS else "", "t": span if span in SPANS else ""}


def _listing(cursor, *, sort: str = "", span: str = "") -> str:
    """The query of one page of a listing: a hundred posts after the cursor."""
    query = {"raw_json": "1", "limit": str(PAGE)}
    if cursor:
        query["after"] = str(cursor)
    if sort:
        query["sort"] = sort
    if span:
        query["t"] = span
    return urlencode(query)
