"""Native Pixiv client — the Pixiv adapter's read path.

Pixiv has a real (if unofficial) API: the mobile app API gallery-dl drives
(`PixivAppAPI`). Per the downloader ground rule — gallery-dl is the
known-working base — this client mirrors gallery-dl 1.32.5's request profile
EXACTLY: the same iOS app headers on every request, the same OAuth
refresh-token dance against oauth.secure.pixiv.net (X-Client-Time +
X-Client-Hash), and the same `/v1/user/illusts` walk paginated by `next_url`.
Deviating from that profile is how the SubscribeStar/Patreon spikes broke, so
any change here should be diffed against gallery-dl's extractor first.

Feed shape (characterized from gallery-dl 1.32.5, extractor/pixiv.py):
  - `GET /v1/user/illusts?user_id=<id>` returns `{"illusts": [work...],
    "next_url": "https://app-api...?user_id=..&offset=30" | null}`.
  - Pagination: re-issue the SAME endpoint with `next_url`'s query params. The
    query string doubles as our resumable page cursor (re-fetching it re-serves
    the same page — the ingest-core resume contract).
  - A work carries id/title/type(illust|manga|ugoira)/caption(HTML)/
    create_date(ISO+09:00)/tags[{name,translated_name}]/user/page_count/
    x_restrict/series/total_view/total_bookmarks/meta_single_page/meta_pages.
  - Files: multi-page → meta_pages[].image_urls.original; single page →
    meta_single_page.original_image_url; ugoira → `/v1/ugoira/metadata` zip
    (600x600 → 1920x1080 URL swap, gallery-dl's default non-original mode).

`campaign_id` for Pixiv is the numeric user id (extracted from the source URL
by `user_id_from_url` — no network resolver needed).

Gated works: pixiv serves a `https://s.pximg.net/common/images/limit_*.png`
placeholder as the "original" when a work is blocked for this account
(sanity-level filter, my-pixiv lock, deleted). gallery-dl's fallback for those
is a web-AJAX scrape that needs PHPSESSID browser cookies — FC stores only the
OAuth refresh token, so (exactly like our previous gallery-dl configuration,
which warned "No PHPSESSID cookie set") those works are skipped, via the
post_is_gated seam. Auth failures are loud (rotate the refresh token); a
response missing the fields we depend on is DRIFT (update this client).

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

from __future__ import annotations

import hashlib
import logging
import time
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from urllib.parse import parse_qsl, urlsplit

import requests

from ..utils.paths import safe_ext
from .native_ingest_common import (
    _MAX_429_RETRIES,
    NativeAuthError,
    NativeDriftError,
    NativeIngestError,
    make_session,
    retry_after_seconds,
)

log = logging.getLogger(__name__)

_TIMEOUT_SECONDS = 30.0
_API_ROOT = "https://app-api.pixiv.net"
_OAUTH_URL = "https://oauth.secure.pixiv.net/auth/token"

# gallery-dl's public Pixiv-app credentials (PixivAppAPI, also pixivpy's) —
# these identify the official iOS app to the API, NOT the operator; the
# operator's identity is the OAuth refresh token.
_CLIENT_ID = "MOBrBDS8blbauoSck0ZfDbtuzpyT"
_CLIENT_SECRET = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj"
_HASH_SECRET = (
    "28c1fdd170a5204386cb1313c7077b34"
    "f83e4aaf4aa829ce78c231e05b0bae2c"
)

# The exact header set gallery-dl 1.32.5 installs on its session — the proven
# app-API request profile. The Referer also unlocks i.pximg.net media GETs
# (403 without it), so the downloader reuses this constant.
PIXIV_APP_HEADERS = {
    "App-OS": "ios",
    "App-OS-Version": "16.7.2",
    "App-Version": "7.19.1",
    "User-Agent": "PixivIOSApp/7.19.1 (iOS 16.7.2; iPhone12,8)",
    "Referer": "https://app-api.pixiv.net/",
}

# Placeholder image prefix pixiv serves instead of a blocked work's original
# (limit_sanity_level / limit_mypixiv / limit_unknown variants).
_LIMIT_URL = "https://s.pximg.net/common/images/limit_"

# The app API reports rate-limiting as an error MESSAGE (often on HTTP 403),
# not only as HTTP 429. gallery-dl sleeps 300s in-walk; sleeping that long
# inside our time-boxed chunk would eat the whole budget, so we surface it as
# a typed 429 and let download_service's cooldown machinery honor the wait.
_RATE_LIMIT_RETRY_AFTER = 300.0

_TITLE_MAX = 50  # gallery-dl pixiv filename template: {title[:50]}

_RATINGS = {0: "General", 1: "R-18", 2: "R-18G"}


class PixivAPIError(NativeIngestError):
    """Base for native Pixiv client failures. status_code / retry_after are
    inherited from NativeIngestError."""


class PixivAuthError(PixivAPIError, NativeAuthError):
    """Auth failure — missing/expired/revoked OAuth refresh token. Fix =
    rotate the credential (Settings → Credentials → Pixiv), not update the
    client. Maps to error_type 'auth_error'."""


class PixivDriftError(PixivAPIError, NativeDriftError):
    """A response did not match the shape this client depends on (missing
    `illusts`, un-parseable JSON where JSON was promised). Fail loud so the
    run flags 'the Pixiv app API changed' instead of silently importing
    nothing. Maps to API_DRIFT."""


@dataclass
class MediaItem:
    """One resolved downloadable file belonging to a Pixiv work.

    Fields mirror the other native clients' MediaItem so the downloader and
    ledger are structurally the same. Pixiv original URLs carry no content
    hash, so `filehash` is always None and the ledger keys on
    `<post_id>:<media_id>` where media_id is `p<num>` (page) or `ugoira`
    (the frame zip) — stable across URL-shape drift.
    """

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


def user_id_from_url(url: str) -> str | None:
    """The numeric pixiv user id from a source URL, or None.

    Handles the modern forms FC accepts as sources
    (https://www.pixiv.net/users/<id>, /en/users/<id>) plus the legacy
    member.php?id=<id>. This IS the campaign id — no network resolver.
    """
    parts = urlsplit(url or "")
    if "pixiv.net" not in parts.netloc:
        return None
    segs = [s for s in parts.path.split("/") if s]
    if segs and segs[0] == "en":
        segs = segs[1:]
    if len(segs) >= 2 and segs[0] == "users" and segs[1].isdigit():
        return segs[1]
    if segs and segs[0] == "member.php":
        qid = dict(parse_qsl(parts.query)).get("id", "")
        if qid.isdigit():
            return qid
    return None


def _work_filename(work: dict, num: int, url: str) -> str:
    """gallery-dl layout parity: `{id}_{title[:50]}_{num:>02}.{extension}`
    (the downloader sanitizes the final segment)."""
    title = work.get("title")
    title50 = (title if isinstance(title, str) else "")[:_TITLE_MAX]
    ext = safe_ext(urlsplit(url).path.rsplit("/", 1)[-1])
    return f"{work.get('id')}_{title50}_{num:02d}{ext}"


class PixivClient:
    """Synchronous Pixiv app-API read client. Construct with the operator's
    OAuth refresh token (the same token-type Credential the gallery-dl path
    consumed as `extractor.pixiv.refresh-token`)."""

    def __init__(
        self,
        refresh_token: str | None,
        *,
        request_sleep: float = 0.0,
        max_retries: int = _MAX_429_RETRIES,
        session: requests.Session | None = None,
    ):
        self.refresh_token = refresh_token
        self._request_sleep = request_sleep or 0.0
        self._max_retries = max_retries
        # No cookies — the app API authenticates via the Bearer token _login
        # installs. make_session still supplies the retry/UA plumbing; the
        # extra_headers overwrite its browser UA with the app profile.
        self._session = (
            session if session is not None
            else make_session(None, extra_headers=PIXIV_APP_HEADERS)
        )
        self._authed_user: dict = {}
        # Monotonic deadline after which the access token must be refreshed;
        # 0 forces a refresh on first use.
        self._token_deadline = 0.0

    # -- auth ----------------------------------------------------------------

    def _login(self) -> None:
        """Exchange the refresh token for a Bearer access token (gallery-dl's
        `_login_impl`, including the X-Client-Time/X-Client-Hash pair the
        endpoint validates). No-op while the current token is still fresh."""
        if time.monotonic() < self._token_deadline:
            return
        if not self.refresh_token:
            raise PixivAuthError(
                "No Pixiv refresh token configured — add the OAuth refresh "
                "token as the Pixiv credential (token type)."
            )
        # gallery-dl stamps naive-UTC with a literal +00:00 suffix.
        now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S+00:00")
        headers = {
            "X-Client-Time": now,
            "X-Client-Hash": hashlib.md5(
                (now + _HASH_SECRET).encode()
            ).hexdigest(),
        }
        data = {
            "client_id": _CLIENT_ID,
            "client_secret": _CLIENT_SECRET,
            "grant_type": "refresh_token",
            "refresh_token": self.refresh_token,
            "get_secure_url": "1",
        }
        try:
            resp = self._session.post(
                _OAUTH_URL, data=data, headers=headers,
                timeout=_TIMEOUT_SECONDS,
            )
        except requests.RequestException as exc:
            raise PixivAPIError(f"Pixiv OAuth request failed: {exc}") from exc
        if resp.status_code >= 400:
            raise PixivAuthError(
                "Pixiv rejected the refresh token (HTTP "
                f"{resp.status_code}) — rotate the Pixiv credential.",
                status_code=resp.status_code,
            )
        try:
            payload = resp.json()["response"]
            access = payload["access_token"]
        except (ValueError, KeyError, TypeError) as exc:
            raise PixivDriftError(
                f"Pixiv OAuth response shape changed: {exc}"
            ) from exc
        self._authed_user = payload.get("user") or {}
        self._session.headers["Authorization"] = f"Bearer {access}"
        # expires_in is 3600 today; refresh 60s early so a long walk never
        # rides an expiring token into a spurious 400.
        expires_in = payload.get("expires_in")
        lifetime = float(expires_in) if isinstance(expires_in, (int, float)) else 3600.0
        self._token_deadline = time.monotonic() + max(60.0, lifetime - 60.0)

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

    def _call(self, endpoint: str, params: dict) -> dict:
        """Authenticated app-API GET → parsed JSON body, with the shared 429
        backoff and the loud auth/drift/rate-limit mapping."""
        self._login()
        if self._request_sleep > 0:
            time.sleep(self._request_sleep)
        url = _API_ROOT + endpoint
        attempt = 0
        while True:
            try:
                resp = self._session.get(
                    url, params=params, timeout=_TIMEOUT_SECONDS
                )
            except requests.RequestException as exc:
                raise PixivAPIError(
                    f"Pixiv request failed ({endpoint}): {exc}"
                ) from exc
            if resp.status_code == 429 and attempt < self._max_retries:
                attempt += 1
                delay = retry_after_seconds(resp, attempt)
                log.warning(
                    "Pixiv 429 (%s) — backing off %.1fs (retry %d/%d)",
                    endpoint, delay, attempt, self._max_retries,
                )
                time.sleep(delay)
                continue
            break

        try:
            body = resp.json()
        except ValueError as exc:
            raise PixivDriftError(
                f"Pixiv returned non-JSON for {endpoint} "
                f"(HTTP {resp.status_code})"
            ) from exc

        error = body.get("error") if isinstance(body, dict) else None
        message = ""
        if isinstance(error, dict):
            message = str(
                error.get("user_message") or error.get("message") or ""
            )
        # Rate limiting first: the app API reports it as an error MESSAGE
        # (often on HTTP 403), which must not be mistaken for an auth failure.
        if resp.status_code == 429 or "rate limit" in message.lower():
            raise PixivAPIError(
                f"Pixiv rate limit hit ({endpoint}): {message or 'HTTP 429'}",
                status_code=429,
                retry_after=_RATE_LIMIT_RETRY_AFTER,
            )
        if resp.status_code in (400, 401, 403):
            # Invalid/expired access token surfaces as 400 invalid_grant-style
            # errors on the app API; 401/403 are straight auth rejections.
            raise PixivAuthError(
                f"Pixiv rejected the request ({endpoint}, HTTP "
                f"{resp.status_code}): {message or 'auth rejected'} — "
                "rotate the Pixiv refresh token.",
                status_code=resp.status_code,
            )
        if resp.status_code >= 400:
            raise PixivAPIError(
                f"Pixiv API error ({endpoint}, HTTP {resp.status_code}): "
                f"{message or 'unknown error'}",
                status_code=resp.status_code,
            )
        if error:
            # HTTP 200 carrying an error object — unexpected, but never
            # silently treat it as data.
            raise PixivAPIError(
                f"Pixiv API error ({endpoint}): {message or error}",
                status_code=resp.status_code,
            )
        return body

    # -- normalization -------------------------------------------------------

    @staticmethod
    def _normalize(work: dict) -> dict:
        """Wrap an app-API work in the `{"id", "attributes", ...}` post shape
        the platform-agnostic core and shared helpers read. The raw work rides
        along under `_work` for extract_media / the post record."""
        title = work.get("title")
        caption = work.get("caption")
        wtype = work.get("type")
        return {
            "id": work.get("id"),
            "attributes": {
                "title": title if isinstance(title, str) else "",
                "content": caption if isinstance(caption, str) else "",
                "published_at": work.get("create_date"),
                "post_type": wtype if isinstance(wtype, str) else "illust",
            },
            "_work": work,
        }

    # -- post-first seams ----------------------------------------------------

    @staticmethod
    def post_record_key(post: dict) -> tuple[str, str] | None:
        """`(ledger_key, post_id)` gating post-record capture through the seen
        ledger (`post:<id>` synthetic key), or None when the work has no id."""
        pid = post.get("id")
        pid = str(pid) if pid is not None else ""
        if not pid:
            return None
        return (f"post:{pid}", pid)

    @staticmethod
    def post_meta(post: dict) -> dict:
        attrs = post.get("attributes") or {}
        return {"title": attrs.get("title") or None, "date": attrs.get("published_at")}

    @staticmethod
    def post_is_gated(post: dict) -> bool:
        """True when this account cannot fetch the work's real files: pixiv
        substitutes a `limit_*` placeholder for the original (sanity-level
        filter / my-pixiv lock / deleted), or zeroes the author (deleted
        account). Mirrors #874 semantics: gated content leaves NO trace — a
        placeholder thumbnail and an empty stub would only pollute the
        archive. (gallery-dl's PHPSESSID web-scrape fallback for these is out
        of scope: FC holds no pixiv browser cookies — module docstring.)"""
        work = post.get("_work") or {}
        user = work.get("user") or {}
        if not user.get("id"):
            return True
        if work.get("meta_pages"):
            return False
        single = work.get("meta_single_page") or {}
        original = single.get("original_image_url")
        return isinstance(original, str) and original.startswith(_LIMIT_URL)

    # -- media ---------------------------------------------------------------

    def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]:
        """Resolve a work's downloadable files (gallery-dl's `_extract_files`):
        multi-page originals, the single-page original, or the ugoira frame
        zip. `included_index` is unused (pixiv works are self-contained)."""
        work = post.get("_work") or {}
        pid = str(post.get("id") or "")
        if not pid or self.post_is_gated(post):
            return []

        if work.get("type") == "ugoira":
            return self._ugoira_media(work, pid)

        meta_pages = work.get("meta_pages") or []
        if meta_pages:
            items = []
            for num, page in enumerate(meta_pages):
                urls = page.get("image_urls") or {}
                url = urls.get("original")
                if not isinstance(url, str) or not url:
                    continue
                items.append(
                    MediaItem(
                        url=url,
                        filename=_work_filename(work, num, url),
                        kind="image",
                        filehash=None,
                        post_id=pid,
                        media_id=f"p{num}",
                    )
                )
            return items

        single = work.get("meta_single_page") or {}
        url = single.get("original_image_url")
        if not isinstance(url, str) or not url or url.startswith(_LIMIT_URL):
            return []
        return [
            MediaItem(
                url=url,
                filename=_work_filename(work, 0, url),
                kind="image",
                filehash=None,
                post_id=pid,
                media_id="p0",
            )
        ]

    def _ugoira_meta(self, work: dict, pid: str) -> dict | None:
        """Fetch + memoize the ugoira metadata (frames + zip urls) for a work.

        Idempotent and cached on the work dict, so the post record and the
        media extraction share ONE `/v1/ugoira/metadata` call regardless of
        which runs first (the core writes the post record BEFORE it extracts
        media). Returns None — and caches the miss — on a non-auth failure
        (matching gallery-dl's downgrade); auth failures stay loud."""
        if "_ugoira_meta" in work:
            return work["_ugoira_meta"]
        try:
            body = self._call("/v1/ugoira/metadata", {"illust_id": pid})
            meta = body["ugoira_metadata"]
        except PixivAuthError:
            raise
        except (PixivAPIError, KeyError, TypeError) as exc:
            log.warning("Pixiv ugoira metadata failed for %s: %s", pid, exc)
            work["_ugoira_meta"] = None
            return None
        work["_ugoira_meta"] = meta
        # Frame delays: a future ugoira→video conversion needs the timings (the
        # zip alone has none), so the post record captures them.
        work["_ugoira_frames"] = meta.get("frames") or []
        return meta

    def fetch_ugoira_frames(self, post: dict) -> None:
        """Populate `post['_work']['_ugoira_frames']` for an ugoira post (no-op
        otherwise). The core writes the post record BEFORE extract_media, so
        without this the frame timings would never reach the record; this
        fetches (and memoizes, so extract_media reuses it) the metadata. Injected
        into the downloader by the ingester, mirroring Patreon's content_fetcher.
        Auth errors propagate; other failures leave frames unset."""
        work = post.get("_work") or {}
        if work.get("type") != "ugoira":
            return
        pid = str(post.get("id") or "")
        if pid:
            self._ugoira_meta(work, pid)

    def _ugoira_media(self, work: dict, pid: str) -> list[MediaItem]:
        """The ugoira frame zip (gallery-dl's default non-original mode):
        `/v1/ugoira/metadata` → zip_urls.medium with the 600x600→1920x1080
        swap. A metadata failure downgrades to 'no media' with a warning
        (matching gallery-dl) instead of failing the walk — except auth
        failures, which stay loud."""
        meta = self._ugoira_meta(work, pid)
        if meta is None:
            return []
        try:
            zip_url = meta["zip_urls"]["medium"]
        except (KeyError, TypeError) as exc:
            log.warning("Pixiv ugoira zip url missing for %s: %s", pid, exc)
            return []
        url = zip_url.replace("_ugoira600x600", "_ugoira1920x1080", 1)
        return [
            MediaItem(
                url=url,
                filename=_work_filename(work, 0, url),
                kind="ugoira",
                filehash=None,
                post_id=pid,
                media_id="ugoira",
            )
        ]

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

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

        `campaign_id` is the numeric pixiv user id. `cursor` is the query
        string of the app API's `next_url` (offset pagination); None fetches
        page 1. The yielded `page_cursor` is the cursor that FETCHED this
        work's page, so the core checkpoints a value that re-serves the same
        page on resume (the shared cursor contract)."""
        if not str(campaign_id or "").isdigit():
            raise PixivDriftError(
                f"Pixiv campaign id must be a numeric user id, got "
                f"{campaign_id!r}"
            )
        current = cursor
        while True:
            page_cursor = current
            if current is None:
                params: dict = {"user_id": campaign_id}
            else:
                params = dict(parse_qsl(current))
            data = self._call("/v1/user/illusts", params)
            works = data.get("illusts")
            if not isinstance(works, list):
                raise PixivDriftError(
                    "Pixiv user-illusts response had no 'illusts' list "
                    f"(keys: {sorted(data)[:8]})"
                )
            for work in works:
                if not isinstance(work, dict):
                    continue
                yield self._normalize(work), {}, page_cursor
            next_url = data.get("next_url")
            if not next_url:
                return
            current = str(next_url).rpartition("?")[2]

    # -- user detail ---------------------------------------------------------

    def resolve_display_name(self, user_id: str) -> str | None:
        """The pixiv user's display name via `/v1/user/detail` (gallery-dl's
        user_detail) — used to name the Artist when a source is added by numeric
        id. None on any failure (the caller falls back to the id)."""
        try:
            body = self._call("/v1/user/detail", {"user_id": str(user_id)})
        except PixivAPIError:
            return None
        name = (body.get("user") or {}).get("name") if isinstance(body, dict) else None
        return name if isinstance(name, str) and name.strip() else None

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

    def verify_auth(self) -> tuple[bool | None, str]:
        """Cheap credential probe: run the OAuth refresh (the thing that fails
        when the token is bad) without walking any feed."""
        try:
            self._token_deadline = 0.0  # force a real refresh
            self._login()
        except PixivAuthError as exc:
            return False, f"Pixiv rejected the credential — {exc}"
        except PixivAPIError as exc:
            return None, f"Couldn't verify (network/HTTP issue): {exc}"
        account = self._authed_user.get("account") or self._authed_user.get("name")
        suffix = f" as {account}" if account else ""
        return True, f"Credentials valid — Pixiv OAuth refresh succeeded{suffix}."


def rating_label(x_restrict) -> str | None:
    """Human rating from pixiv's x_restrict (0/1/2) — written into the post
    record so the archive keeps the R-18 flag without the reader needing to
    know pixiv's numeric scheme."""
    if isinstance(x_restrict, bool) or not isinstance(x_restrict, int):
        return None
    return _RATINGS.get(x_restrict)
