"""Native Patreon JSON:API client (build step 1 of the native ingester).

Clean-room reimplementation of the Patreon `/api/posts` read path. This is a
plain, synchronous client over `requests` — the orchestrator already wraps
sync importer calls, so nothing here needs to be async (mirrors
patreon_resolver.py, which wraps its sync lookup in run_in_executor at the
call site).

Scope (build step 1): fetch + page + parse only. This module is NOT wired into
download_service yet — that is a later step. The public surface here exists so
the later step can drive it:
  - PatreonClient(cookies_path).iter_posts(campaign_id)
        → (post, included_index, page_cursor)
  - extract_media(post, included_index) → list[MediaItem]
  - parse_cursor_from_url(url) → cursor

Drift detection is loud on purpose: Patreon ships JSON:API and the shapes we
depend on (top-level `data`, media resources carrying `file_name`/`url`) are
the contract. If a response comes back as an HTML login page or a media
resource is missing the fields we resolve against, we raise PatreonDriftError
rather than silently yielding empty media — so the later import step surfaces
"Patreon changed something" instead of "creator has no posts".

FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
"""

from __future__ import annotations

import logging
import re
import time
from collections.abc import Iterator
from dataclasses import dataclass
from html import unescape
from pathlib import Path
from urllib.parse import parse_qs, urlsplit

import requests

from ..utils.paths import filehash_from_url
from ..utils.prosemirror import post_body_html
from .native_ingest_common import (
    _MAX_429_RETRIES,
    NativeAuthError,
    NativeDriftError,
    NativeIngestError,
    basename_from_url,
    make_session,
    retry_after_seconds,
)

log = logging.getLogger(__name__)

_POSTS_URL = "https://www.patreon.com/api/posts"
_TIMEOUT_SECONDS = 30.0

# JSON:API request contract (observed from real traffic — see module plan).
_INCLUDE = (
    "campaign,access_rules,attachments,attachments_media,audio,images,media,"
    "native_video_insights,user,user_defined_tags,ti_checks"
)
_FIELDS_POST = (
    # `content` is the legacy flat-HTML body (Patreon now returns it null);
    # `content_json_string` is the current ProseMirror-doc body. Request BOTH —
    # post_body_html() prefers content (old posts) and falls back to converting
    # content_json_string (current posts). #842.
    "content,content_json_string,post_file,image,post_type,published_at,title,"
    "url,patreon_url,current_user_can_view"
)
_FIELDS_MEDIA = "id,image_urls,download_url,metadata,file_name"
_FIELDS_CAMPAIGN = "name,url"

# Inline post `content` is HTML; images are emitted as <img ... src="...">.
# Pull every src; downstream dedup collapses any that duplicate a gallery item
# by filehash. Tolerant of attribute ordering and single/double quotes.
_CONTENT_IMG_RE = re.compile(r"<img\b[^>]*?\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE)


class PatreonAPIError(NativeIngestError):
    """Base for native Patreon client failures. status_code / retry_after are
    inherited from NativeIngestError (HTTP status; 429 Retry-After hint)."""


class PatreonAuthError(PatreonAPIError, NativeAuthError):
    """Authentication / authorization failure — missing or expired session
    cookies, an insufficient pledge tier, or an HTML login/challenge page served
    where JSON was expected. DISTINCT from drift: the fix is rotating the
    credential, not updating the ingester. Maps to error_type 'auth_error'.
    """


class PatreonDriftError(PatreonAPIError, NativeDriftError):
    """A JSON response did not match the JSON:API shape we depend on.

    Raised for: a missing top-level `data` list, `data` not a list, or a media
    resource lacking the `file_name`/`url` fields we resolve against. Fail loud
    so the import step flags API drift (ingester needs update) instead of
    silently importing nothing. An HTML-login / non-JSON body is auth, not
    drift — that raises PatreonAuthError.
    """


@dataclass
class MediaItem:
    """One resolved downloadable item belonging to a post.

    Fields:
      url       — the CDN/download URL to fetch.
      filename  — media `file_name` when present; otherwise the URL basename
                  (NEVER a network call). Bounded/sane extension.
      kind      — one of: "images", "image_large", "attachments", "postfile",
                  "content". Mirrors gallery-dl's `files` content-type names so
                  the later step can honor the same per-source content_types.
      filehash  — the 32-char hex (MD5) segment from the CDN URL, or None if
                  the URL carries no such segment. Used for in-post dedup and
                  the cross-run seen-ledger.
      post_id   — the owning post's id (so a flattened media list stays
                  traceable to its post).
    """

    url: str
    filename: str
    kind: str
    filehash: str | None
    post_id: str


def _filehash(url: str) -> str | None:
    # Delegate to the shared extractor (utils.paths) so capture-time persistence
    # and render-time inline-image matching use the EXACT same identity.
    return filehash_from_url(url)


def parse_cursor_from_url(url: str | None) -> str | None:
    """Extract the `page[cursor]` query param from a links.next URL."""
    if not url:
        return None
    query = urlsplit(url).query
    values = parse_qs(query).get("page[cursor]")
    if values and values[0]:
        return values[0]
    return None


class PatreonClient:
    """Synchronous Patreon JSON:API read client.

    Construct with a path to a Netscape cookies.txt (the same file
    CredentialService.get_cookies_path materializes). Cookies are loaded into a
    requests.Session; no secure-context APIs are used.
    """

    def __init__(
        self,
        cookies_path: str | Path | None,
        *,
        request_sleep: float = 0.0,
        max_retries: int = _MAX_429_RETRIES,
    ):
        self.cookies_path = str(cookies_path) if cookies_path else None
        self._session = make_session(cookies_path, accept="application/vnd.api+json")
        # Politeness: seconds to sleep before each /api/posts page fetch (paces
        # the rate-limited API endpoint). 0 = no pacing. plan #703.
        self._request_sleep = request_sleep or 0.0
        self._max_retries = max_retries

    # -- request -----------------------------------------------------------

    def _params(self, campaign_id: str, cursor: str | None) -> dict[str, str]:
        params = {
            "include": _INCLUDE,
            "fields[post]": _FIELDS_POST,
            "fields[media]": _FIELDS_MEDIA,
            "fields[campaign]": _FIELDS_CAMPAIGN,
            "filter[campaign_id]": campaign_id,
            "filter[contains_exclusive_posts]": "true",
            "filter[is_draft]": "false",
            "sort": "-published_at",
            "json-api-version": "1.0",
        }
        if cursor:
            params["page[cursor]"] = cursor
        return params

    def _fetch(self, campaign_id: str, cursor: str | None) -> dict:
        if self._request_sleep > 0:
            time.sleep(self._request_sleep)  # pace the API endpoint
        attempt = 0
        while True:
            try:
                resp = self._session.get(
                    _POSTS_URL,
                    params=self._params(campaign_id, cursor),
                    timeout=_TIMEOUT_SECONDS,
                )
            except requests.RequestException as exc:
                raise PatreonAPIError(
                    f"Patreon posts request failed (campaign_id={campaign_id}): {exc}"
                ) from exc

            # Transient rate-limit: back off and retry rather than failing the
            # whole walk. Only a PERSISTENT 429 (retries exhausted) falls
            # through to the terminal RATE_LIMITED raise below.
            if resp.status_code == 429 and attempt < self._max_retries:
                attempt += 1
                delay = retry_after_seconds(resp, attempt)
                log.warning(
                    "Patreon 429 (campaign_id=%s) — backing off %.1fs (retry %d/%d)",
                    campaign_id, delay, attempt, self._max_retries,
                )
                time.sleep(delay)
                continue
            break

        if resp.status_code in (401, 403):
            # Auth rejected — expired/missing cookies or an insufficient tier.
            # Actionable as "rotate credentials", so it's auth, not drift/http.
            raise PatreonAuthError(
                f"Patreon posts API returned HTTP {resp.status_code} — auth "
                f"rejected (cookies expired or tier insufficient; "
                f"campaign_id={campaign_id})",
                status_code=resp.status_code,
            )
        if resp.status_code != 200:
            # A persistent 429 (retries exhausted) is terminal RATE_LIMITED — carry
            # the server's raw Retry-After seconds so the cooldown matches its hint
            # (plan #708 B1). Header is uncapped here; the cooldown clamps it.
            retry_after = None
            if resp.status_code == 429:
                hdr = resp.headers.get("Retry-After")
                if hdr:
                    try:
                        retry_after = float(hdr)
                    except (TypeError, ValueError):
                        retry_after = None
            raise PatreonAPIError(
                f"Patreon posts API returned HTTP {resp.status_code} "
                f"(campaign_id={campaign_id})",
                status_code=resp.status_code,
                retry_after=retry_after,
            )
        try:
            payload = resp.json()
        except ValueError as exc:
            # A non-JSON body here is almost always the HTML login/challenge
            # page served when cookies are missing/expired — that is an AUTH
            # failure (rotate cookies), not API drift (update the ingester) and
            # not a transient network error.
            raise PatreonAuthError(
                "Patreon posts API returned a non-JSON response (likely an "
                f"HTML login/challenge page — session expired; "
                f"campaign_id={campaign_id}): {exc}"
            ) from exc
        return payload

    # -- parsing -----------------------------------------------------------

    @staticmethod
    def _transform(response: dict) -> dict:
        """Flatten the JSON:API `included` array for relationship resolution.

        Returns a dict keyed by `(type, id)` → that resource's `attributes`
        (so a post's relationships can be resolved in O(1)). Missing/oddly
        shaped `included` entries are skipped rather than fatal — drift
        detection for the top-level shape lives in _validate_response.
        """
        index: dict[tuple[str, str], dict] = {}
        for inc in response.get("included") or []:
            if not isinstance(inc, dict):
                continue
            rtype = inc.get("type")
            rid = inc.get("id")
            if rtype is None or rid is None:
                continue
            index[(str(rtype), str(rid))] = inc.get("attributes") or {}
        return index

    @staticmethod
    def _validate_response(response: dict) -> None:
        if not isinstance(response, dict):
            raise PatreonDriftError("Patreon response was not a JSON object")
        if "data" not in response:
            raise PatreonDriftError("Patreon response missing top-level 'data' key")
        data = response.get("data")
        if not isinstance(data, list):
            raise PatreonDriftError("Patreon response 'data' was not a list")

    def _related_ids(self, post: dict, rel_name: str) -> list[str]:
        rels = post.get("relationships") or {}
        rel = rels.get(rel_name) or {}
        data = rel.get("data")
        if isinstance(data, dict):  # to-one relationship
            data = [data]
        if not isinstance(data, list):
            return []
        ids: list[str] = []
        for ref in data:
            if isinstance(ref, dict) and ref.get("id") is not None:
                ids.append(str(ref["id"]))
        return ids

    @staticmethod
    def _media_url(attrs: dict) -> str | None:
        """Pick the best fetchable URL for a media resource.

        Prefer the full-size `download_url`; fall back to the largest
        `image_urls` size. gallery-dl prefers download_url too, only dipping
        into image_urls when a smaller configured size is requested — FC
        always wants the original, so download_url first.
        """
        download_url = attrs.get("download_url")
        if isinstance(download_url, str) and download_url:
            return download_url
        image_urls = attrs.get("image_urls")
        if isinstance(image_urls, dict):
            for key in ("original", "full", "large", "default"):
                candidate = image_urls.get(key)
                if isinstance(candidate, str) and candidate:
                    return candidate
            # Otherwise take any non-empty string value.
            for candidate in image_urls.values():
                if isinstance(candidate, str) and candidate:
                    return candidate
        return None

    def _media_item(self, attrs: dict, kind: str, post_id: str) -> MediaItem:
        url = self._media_url(attrs)
        if not url:
            raise PatreonDriftError(
                f"Patreon media (post {post_id}, kind={kind}) had no resolvable URL "
                f"(no download_url / image_urls)"
            )
        # file_name is OPTIONAL: Patreon legitimately serves some gallery images
        # without it (operator-flagged 2026-06-07, BlenderKnight post 73665615),
        # and the URL basename is a fine fallback — the same thing gallery-dl
        # uses. A genuine schema change shows up as no URL (above) or a media id
        # absent from `included` (caller), not a missing name.
        file_name = attrs.get("file_name")
        filename = file_name if isinstance(file_name, str) and file_name else basename_from_url(url)
        return MediaItem(
            url=url,
            filename=filename,
            kind=kind,
            filehash=_filehash(url),
            post_id=post_id,
        )

    def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]:
        """Resolve all downloadable media for one post.

        Walks the kinds in the same order gallery-dl does — images,
        image_large (post cover), attachments, postfile, content (inline
        <img>) — and dedups within the post by filehash (first wins). The
        image_large cover commonly duplicates a gallery image; deduping by
        filehash collapses them to the gallery item (encountered first).
        """
        post_id = str(post.get("id") or "")
        attrs = post.get("attributes") or {}
        items: list[MediaItem] = []

        def _resolve_rel(rel_name: str, kind: str) -> None:
            for mid in self._related_ids(post, rel_name):
                media_attrs = included_index.get(("media", mid))
                if media_attrs is None:
                    # Referenced but not in `included`: a media id with no
                    # resource is drift (we asked for include=media).
                    raise PatreonDriftError(
                        f"Patreon post {post_id} references media {mid} "
                        f"({rel_name}) not present in 'included'"
                    )
                items.append(self._media_item(media_attrs, kind, post_id))

        # 1. gallery images
        _resolve_rel("images", "images")

        # 2. image_large — the post-level cover (`image.large_url`). Not a
        #    media relationship; it lives on the post attributes.
        image = attrs.get("image")
        if isinstance(image, dict):
            large_url = image.get("large_url") or image.get("url")
            if isinstance(large_url, str) and large_url:
                items.append(
                    MediaItem(
                        url=large_url,
                        filename=basename_from_url(large_url),
                        kind="image_large",
                        filehash=_filehash(large_url),
                        post_id=post_id,
                    )
                )

        # 3. attachments
        _resolve_rel("attachments_media", "attachments")

        # 4. postfile — the post's primary attached file (`post_file`).
        post_file = attrs.get("post_file")
        if isinstance(post_file, dict):
            pf_url = post_file.get("url") or post_file.get("download_url")
            if isinstance(pf_url, str) and pf_url:
                pf_name = post_file.get("name")
                filename = (
                    pf_name
                    if isinstance(pf_name, str) and pf_name
                    else basename_from_url(pf_url)
                )
                items.append(
                    MediaItem(
                        url=pf_url,
                        filename=filename,
                        kind="postfile",
                        filehash=_filehash(pf_url),
                        post_id=post_id,
                    )
                )

        # 5. content — inline <img> in the post HTML body.
        content = attrs.get("content")
        if isinstance(content, str) and content:
            for raw_src in _CONTENT_IMG_RE.findall(content):
                src = unescape(raw_src)
                if not src:
                    continue
                items.append(
                    MediaItem(
                        url=src,
                        filename=basename_from_url(src),
                        kind="content",
                        filehash=_filehash(src),
                        post_id=post_id,
                    )
                )

        return _dedup_by_filehash(items)

    @staticmethod
    def post_meta(post: dict) -> dict:
        """Title + published date for a post — for the preview sample (plan #708
        B4). Part of the client contract `ingest_core.Ingester.preview` calls."""
        attrs = post.get("attributes") or {}
        title = attrs.get("title")
        published = attrs.get("published_at")
        return {
            "title": title if isinstance(title, str) else None,
            "date": published if isinstance(published, str) else None,
        }

    @staticmethod
    def post_is_gated(post: dict) -> bool:
        """True when the authenticated account CANNOT view this post's content
        (#874). Patreon serves only BLURRED locked-preview thumbnails for
        paywalled / insufficient-tier posts, and `current_user_can_view` on the
        post attributes is the access flag (it IS in `_FIELDS_POST`). The walk
        skips a gated post ENTIRELY — no media, no post-record stub — so those
        unusable previews never get downloaded.

        Gate ONLY on an explicit `current_user_can_view == False`. A missing /
        None flag (older posts, a sparse fieldset, or API drift) is treated as
        viewable, so we never over-filter accessible posts on an absent field.
        Part of the client contract the core consumes via getattr — an optional
        seam, so stub clients / not-yet-migrated platforms simply never gate."""
        attrs = post.get("attributes") or {}
        return attrs.get("current_user_can_view") is False

    @staticmethod
    def post_record_key(post: dict) -> tuple[str, str] | None:
        """`(ledger_key, post_id)` for a media-less post's seen-ledger entry, or
        None when the post has no id. The synthetic `post:<id>` key lets the
        generic core gate text-post capture through the SAME seen-ledger as media
        — so a text post's body is detail-fetched + recorded ONCE, not re-fetched
        every tick. Part of the client contract the core uses via getattr."""
        pid = post.get("id")
        pid = str(pid) if pid is not None else ""
        if not pid:
            return None
        return (f"post:{pid}", pid)

    # -- iteration ---------------------------------------------------------

    def iter_posts(
        self, campaign_id: str, cursor: str | None = None
    ) -> Iterator[tuple[dict, dict, str | None]]:
        """Yield (post, included_index, page_cursor) for every post in the feed.

        Pages newest→oldest via `links.next`, validating each response for
        drift before yielding. The triple gives a caller everything it needs to
        resolve and checkpoint without re-fetching:
          - post           — the raw post resource.
          - included_index — the page's flattened `included` (the same object
                             for every post on a page), to pass straight to
                             extract_media(post, included_index).
          - page_cursor    — the cursor that FETCHED this post's page (None for
                             the first page). The caller checkpoints THIS value,
                             matching the existing backfill cursor logic where
                             the saved cursor re-fetches the page being
                             processed (so a chunk cut mid-page resumes the page,
                             not the one after it).
        """
        current_cursor = cursor
        while True:
            response = self._fetch(campaign_id, current_cursor)
            self._validate_response(response)
            page_cursor = current_cursor
            included_index = self._transform(response)
            for post in response.get("data") or []:
                if isinstance(post, dict):
                    yield post, included_index, page_cursor
            next_url = (response.get("links") or {}).get("next")
            next_cursor = parse_cursor_from_url(next_url)
            if not next_cursor:
                return
            current_cursor = next_cursor

    # -- detail (full body enrichment) -------------------------------------

    def fetch_post_detail_content(self, post_id: str) -> str | None:
        """Best-effort fetch of a post's body (as HTML) from the per-post DETAIL
        endpoint (`/api/posts/{id}`).

        Patreon deprecated the flat `content` HTML field — it returns null on the
        feed AND the detail endpoint, for every post type. The real body now lives
        in `content_json_string` (a ProseMirror doc), and is only returned under
        the DEFAULT post fieldset: a sparse `fields[post]=content` request OMITS
        it (confirmed against the live API 2026-06-15). So we request the default
        fieldset (no `fields[post]`) and resolve the body via `post_body_html`
        (content → else convert content_json_string). The downloader calls this to
        enrich a post whose feed body was empty before writing the sidecar.

        Best-effort BY DESIGN: a body we can't fetch must never fail the walk.
        Every failure path returns None rather than raising — distinct from the
        loud drift/auth raises on the feed path, which gate real downloads.
        """
        if not post_id:
            return None
        if self._request_sleep > 0:
            time.sleep(self._request_sleep)  # pace the API endpoint (plan #703)
        # No `fields[post]` — the default fieldset is the only shape that returns
        # content_json_string (the body). A sparse fieldset nulls it out.
        try:
            resp = self._session.get(f"{_POSTS_URL}/{post_id}", timeout=_TIMEOUT_SECONDS)
        except requests.RequestException as exc:
            log.warning("Patreon post-detail fetch failed (post %s): %s", post_id, exc)
            return None
        if resp.status_code != 200:
            log.warning(
                "Patreon post-detail fetch HTTP %s (post %s)", resp.status_code, post_id
            )
            return None
        try:
            payload = resp.json()
        except ValueError:
            return None
        data = payload.get("data") if isinstance(payload, dict) else None
        attrs = data.get("attributes") if isinstance(data, dict) else None
        body = post_body_html(attrs)
        if body and body.strip():
            log.info("post-detail: fetched %d chars (post %s)", len(body), post_id)
            return body
        ptype = attrs.get("post_type") if isinstance(attrs, dict) else None
        log.info(
            "post-detail: no body (post %s, post_type=%s)", post_id, ptype,
        )
        return None

    # -- verify ------------------------------------------------------------

    def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]:
        """Cheap auth probe: fetch the first `/api/posts` page and report whether
        the credential authenticated, WITHOUT downloading anything.

        Returns `(ok, message)` matching the credential-verify contract:
          - True  — authenticated (the feed returned a valid JSON:API page).
          - False — the credential was rejected (PatreonAuthError: 401/403, or an
                    HTML login page → cookies expired / tier insufficient).
          - None  — inconclusive: API drift (our parser is stale, not a cred
                    problem) or a transient network/HTTP error.
        """
        try:
            response = self._fetch(campaign_id, None)
            self._validate_response(response)
        except PatreonAuthError as exc:
            return False, f"Patreon rejected the credential — {exc}"
        except PatreonDriftError as exc:
            return None, f"Couldn't verify — Patreon's API shape changed: {exc}"
        except PatreonAPIError as exc:
            return None, f"Couldn't verify (network/HTTP issue): {exc}"
        return True, "Credentials valid — the Patreon feed authenticated."


def _dedup_by_filehash(items: list[MediaItem]) -> list[MediaItem]:
    """Drop later items sharing a filehash with an earlier one (first wins).

    Items with no filehash (None) are never deduped against each other — we
    can't prove they're the same file, so keep them all.
    """
    seen: set[str] = set()
    out: list[MediaItem] = []
    for item in items:
        if item.filehash is not None:
            if item.filehash in seen:
                continue
            seen.add(item.filehash)
        out.append(item)
    return out
