"""Patreon vanity → campaign ID resolver.

When gallery-dl fails with "Failed to extract campaign ID" on a Patreon
source URL, call resolve_campaign_id(vanity, cookies_path) to look up
the campaign ID via Patreon's public campaigns API. Caller then retries
with `patreon.com/id:<campaign_id>` to bypass the broken vanity path.

Uses the stdlib `requests` (already a transitive dep via gallery-dl) so
we don't add `aiohttp`. The sync call is wrapped in run_in_executor so
the calling async code stays non-blocking.

Never raises. Returns None on any error (network, auth, parse, missing
match).
"""

from __future__ import annotations

import asyncio
import http.cookiejar
import logging
import os
import re

import requests

log = logging.getLogger(__name__)

_CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns"
_POSTS_API = "https://www.patreon.com/api/posts"

# A source URL of the form `.../id:<digits>` already carries the campaign id
# (no lookup needed). The vanity regex deliberately EXCLUDES the id: form so the
# two paths don't overlap.
#
# Patreon serves the creator vanity under several path prefixes — bare
# (`patreon.com/Atole`), `c/` (`patreon.com/c/Atole`), and `cw/`
# (`patreon.com/cw/Atole`, its current "creator workspace" URL). The optional
# prefix group must list `cw/` BEFORE `c/` so the longer prefix wins — otherwise
# `cw/Atole` matches the bare branch and yields vanity="cw" (operator-flagged
# 2026-06-07: every `/cw/` source failed resolution on vanity="cw").
_ID_URL_RE = re.compile(r"/id:(\d+)")
# A single-post permalink — patreon.com/posts/<slug>-<post_id> (or bare
# /posts/<post_id>). The trailing digits are the post id; the creator's
# campaign is resolved from the post itself (operator-flagged 2026-06-07: a
# /posts/ source resolved vanity="posts"). `posts/` is excluded from the vanity
# regex so it never masquerades as a creator slug.
_POST_URL_RE = re.compile(r"/posts/(?:[^/?#]*-)?(\d+)(?:[/?#]|$)")
_VANITY_PREFIXES = ("cw/", "c/")
_VANITY_RE = re.compile(
    r"^https?://(?:www\.)?patreon\.com/(?:cw/|c/)?(?!(?:id:|posts/))([^/?#]+)",
    re.IGNORECASE,
)
_USER_AGENT = (
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
_TIMEOUT_SECONDS = 10.0

# Fallback resolution: Patreon's `/api/campaigns?filter[vanity]=` lookup has
# proven unreliable (returns empty `data` for creators that clearly exist —
# operator-flagged 2026-06-06). gallery-dl never used that endpoint; it scrapes
# the campaign id out of the creator page's bootstrap JSON. We do the same as a
# fallback: fetch the creator page HTML and pull the first campaign id out of
# any of these embeddings (ordered most- to least-specific).
_PAGE_CAMPAIGN_ID_PATTERNS = (
    re.compile(r'"id":\s*"(\d+)",\s*"type":\s*"campaign"'),
    re.compile(r'"campaign":\s*\{\s*"data":\s*\{\s*"id":\s*"(\d+)"'),
    re.compile(r"/api/campaigns/(\d+)"),
    re.compile(r'"campaign_id":\s*"?(\d+)'),
)


def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None:
    if not cookies_path or not os.path.isfile(cookies_path):
        return None
    try:
        jar = http.cookiejar.MozillaCookieJar(cookies_path)
        jar.load(ignore_discard=True, ignore_expires=True)
        return jar
    except (OSError, http.cookiejar.LoadError) as exc:
        log.debug("Could not load cookies from %s: %s", cookies_path, exc)
        return None


def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
    """Resolve a vanity to a campaign id: try the campaigns API first (cheap,
    structured), then fall back to scraping the creator page (robust against the
    API's empty-data failures). Returns None only when both miss."""
    return _lookup_via_api(vanity, cookies_path) or _lookup_via_page(
        vanity, cookies_path
    )


def _campaigns_api_first(vanity: str, cookies_path: str | None) -> dict | None:
    """The first `data` object from Patreon's campaigns API filtered by vanity
    (`?filter[vanity]=<vanity>&fields[campaign]=name`), or None on any failure
    (network / non-200 / non-JSON / empty). The single request shape shared by
    _lookup_via_api (plucks the campaign id) and resolve_display_name (plucks the
    display name)."""
    jar = _load_cookie_jar(cookies_path)
    try:
        resp = requests.get(
            _CAMPAIGNS_URL,
            params={"filter[vanity]": vanity, "fields[campaign]": "name"},
            headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
            cookies=jar,
            timeout=_TIMEOUT_SECONDS,
        )
    except requests.RequestException as exc:
        log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc)
        return None
    if resp.status_code != 200:
        log.warning(
            "Patreon campaigns API returned HTTP %d for vanity=%s",
            resp.status_code, vanity,
        )
        return None
    try:
        payload = resp.json()
    except ValueError as exc:
        log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc)
        return None
    data = payload.get("data") if isinstance(payload, dict) else None
    if not isinstance(data, list) or not data or not isinstance(data[0], dict):
        return None
    return data[0]


def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
    first = _campaigns_api_first(vanity, cookies_path)
    if first is None:
        return None
    campaign_id = first.get("id")
    if not isinstance(campaign_id, str) or not campaign_id:
        return None
    log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id)
    return campaign_id


def resolve_display_name(vanity: str, cookies_path: str | None) -> str | None:
    """The Patreon campaign's display name for `vanity` via the campaigns API
    (`fields[campaign]=name`), used to name the Artist at add-time (#130). None
    on any failure — the caller falls back to the vanity handle. Sync: call from
    an executor."""
    first = _campaigns_api_first(vanity, cookies_path)
    if first is None:
        return None
    name = (first.get("attributes") or {}).get("name")
    return name.strip() if isinstance(name, str) and name.strip() else None


def _scrape_campaign_id(html: str) -> str | None:
    """First campaign id found in creator-page HTML via the known embeddings."""
    if not isinstance(html, str):
        return None
    for pat in _PAGE_CAMPAIGN_ID_PATTERNS:
        m = pat.search(html)
        if m:
            return m.group(1)
    return None


def _lookup_via_page(vanity: str, cookies_path: str | None) -> str | None:
    """Fallback: GET the creator page and scrape the campaign id from the page
    bootstrap (gallery-dl's method). Tries both the bare and `/c/` vanity paths
    Patreon redirects between. Never raises."""
    jar = _load_cookie_jar(cookies_path)
    headers = {"User-Agent": _USER_AGENT, "Accept": "text/html"}
    # Try the bare vanity and every known creator-path prefix Patreon
    # redirects between (c/, cw/) — the one the source used isn't known here
    # (extract_vanity already stripped it).
    page_urls = [f"https://www.patreon.com/{vanity}"]
    page_urls += [f"https://www.patreon.com/{p}{vanity}" for p in _VANITY_PREFIXES]
    for page_url in page_urls:
        try:
            resp = requests.get(
                page_url,
                headers=headers,
                cookies=jar,
                timeout=_TIMEOUT_SECONDS,
                allow_redirects=True,
            )
        except requests.RequestException as exc:
            log.warning("Patreon creator-page fetch failed for %s: %s", page_url, exc)
            continue
        if resp.status_code != 200:
            continue
        campaign_id = _scrape_campaign_id(resp.text)
        if campaign_id:
            log.info(
                "Resolved Patreon vanity=%s → campaign_id=%s via creator page",
                vanity, campaign_id,
            )
            return campaign_id
    return None


def _lookup_campaign_from_post(post_id: str, cookies_path: str | None) -> str | None:
    """Resolve the owning campaign id from a single post id via the Patreon
    post API (`/api/posts/<id>?include=campaign`). A `/posts/` source URL points
    at one post, but a subscription walks the whole creator — so we follow the
    post to its campaign. Never raises."""
    jar = _load_cookie_jar(cookies_path)
    headers = {"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"}
    params = {"include": "campaign", "fields[campaign]": "name"}
    url = f"{_POSTS_API}/{post_id}"
    try:
        resp = requests.get(
            url, params=params, headers=headers, cookies=jar,
            timeout=_TIMEOUT_SECONDS,
        )
    except requests.RequestException as exc:
        log.warning("Patreon post lookup failed for post=%s: %s", post_id, exc)
        return None
    if resp.status_code != 200:
        log.warning("Patreon post API returned HTTP %d for 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
    if not isinstance(data, dict):
        return None
    campaign = ((data.get("relationships") or {}).get("campaign") or {}).get("data") or {}
    campaign_id = campaign.get("id")
    if isinstance(campaign_id, str) and campaign_id:
        log.info("Resolved Patreon post=%s → campaign_id=%s", post_id, campaign_id)
        return campaign_id
    return None


async def resolve_campaign_id(
    vanity: str,
    cookies_path: str | None,
) -> str | None:
    """Async wrapper. Returns the campaign id string or None on any failure.
    Never raises."""
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, _sync_lookup, vanity, cookies_path)


async def resolve_campaign_from_post(
    post_id: str, cookies_path: str | None,
) -> str | None:
    """Async wrapper around _lookup_campaign_from_post. Never raises."""
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(
        None, _lookup_campaign_from_post, post_id, cookies_path
    )


def extract_vanity(url: str) -> str | None:
    """The vanity slug from a Patreon creator URL, or None for an `id:` URL."""
    m = _VANITY_RE.match(url or "")
    return m.group(1) if m else None


async def resolve_campaign_id_for_source(
    url: str,
    cookies_path: str | None,
    overrides: dict | None,
) -> tuple[str | None, str | None]:
    """Resolve a Patreon source to its campaign id — the single resolution path
    shared by the download ingester and the credential-verify probe.

    Order: cached `patreon_campaign_id` override → an `id:<digits>` URL → a
    `/posts/<id>` permalink (resolve the owning campaign from the post) → a
    vanity lookup against the campaigns API. Returns
    `(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None whenever
    a lookup actually ran, so the caller caches it on the source (the
    override/id: paths needed no lookup). `(None, None)` when unresolvable.
    Never raises.
    """
    overrides = overrides or {}
    cached = overrides.get("patreon_campaign_id")
    if cached:
        return cached, None
    id_match = _ID_URL_RE.search(url or "")
    if id_match:
        return id_match.group(1), None
    # A single-post URL → follow the post to its creator's campaign so the
    # source subscribes to the whole feed (a subscription isn't one post).
    post_match = _POST_URL_RE.search(url or "")
    if post_match:
        resolved = await resolve_campaign_from_post(post_match.group(1), cookies_path)
        return resolved, resolved
    vanity = extract_vanity(url)
    if vanity:
        resolved = await resolve_campaign_id(vanity, cookies_path)
        return resolved, resolved
    return None, None
