"""Shared forge/CI JSON-over-HTTP helper — the client half of the rate budget (PAPER-119).

mgf-fed's REST surface (today: the Woodpecker API in ``cli/_ping.py``; any future
Codeberg REST call MUST route through here too) enforces the federation's
client-side rate discipline (mgf-standard PAPER-110 / ``activate-ci.md``
§"Codeberg API rate-budget") *by construction*, so an agent driving mgf-fed at
automation speed cannot retry-hammer a forge into its hourly 429 wall:

- **HTTP 429** → bounded exponential backoff **+ jitter** (honoring a numeric
  ``Retry-After`` when the server sends one), then a **hard stop**: the final
  429 propagates to the caller — never an unbounded retry loop.
- Every other error propagates immediately (a 401/404 is a fact, not a
  transient).

Pure stdlib. Pipeline-status polling stays on the self-hosted Woodpecker API by
construction — mgf-fed has no Codeberg commit-status client at all.
"""

from __future__ import annotations

import json
import random
import time
import urllib.error
import urllib.request
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Callable

# PAPER-119: never retry-hammer a 429. A handful of increasingly-spaced attempts,
# then surface the wall to a human (the observed failure wedged merges ~an hour;
# deepening the window only extends it).
MAX_429_RETRIES = 3
_BACKOFF_BASE_S = 5.0
_BACKOFF_CAP_S = 120.0


def _backoff_delay(attempt: int, retry_after: str | None) -> float:
    """Delay before retry ``attempt`` (0-based): server ``Retry-After`` when sane,
    else exponential base*2^attempt, both jittered ±25% and capped."""
    if retry_after:
        try:
            base = min(float(retry_after), _BACKOFF_CAP_S)
        except ValueError:
            base = _BACKOFF_BASE_S * (2**attempt)
    else:
        base = _BACKOFF_BASE_S * (2**attempt)
    base = min(base, _BACKOFF_CAP_S)
    return base * random.uniform(0.75, 1.25)  # noqa: S311 — jitter, not crypto


def request_json(
    url: str,
    *,
    method: str = "GET",
    token: str | None = None,
    payload: dict[str, Any] | None = None,
    timeout: float = 30.0,
    max_429_retries: int = MAX_429_RETRIES,
    sleep: Callable[[float], None] = time.sleep,
) -> Any:
    """One JSON API call with the PAPER-119 429 budget. Returns the decoded JSON
    (an object for most endpoints; some Woodpecker list endpoints return an
    array). Raises ``urllib.error.*`` on non-429 failures immediately, and
    re-raises the LAST 429 after the bounded backoff attempts are exhausted
    (the hard stop)."""
    data = json.dumps(payload).encode() if payload is not None else None
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    for attempt in range(max_429_retries + 1):
        req = urllib.request.Request(  # noqa: S310 — fixed https API hosts, not user-controlled
            url, data=data, method=method, headers=headers
        )
        try:
            with urllib.request.urlopen(req, timeout=timeout) as resp:  # noqa: S310
                body = resp.read().decode()
                return json.loads(body) if body.strip() else {}
        except urllib.error.HTTPError as exc:
            if exc.code != 429 or attempt >= max_429_retries:
                raise  # non-transient, or the hard stop — never hammer the wall
            sleep(_backoff_delay(attempt, exc.headers.get("Retry-After")))
    raise AssertionError("unreachable")  # pragma: no cover


__all__ = ["MAX_429_RETRIES", "request_json"]
