#!/usr/bin/env python3
"""Open one PR across many repos — a wave — without tripping the title throttle.

THE TWO FORGEJO LIMITERS, STATED CORRECTLY
------------------------------------------
Forgejo/Gitea has **two distinct limiters** on issue/PR creation, and the whole
point of this module is that they need OPPOSITE remedies:

1. **Title similarity — an anti-spam rule, NOT a rate limit.** It blocks::

       "magogi-admin" posted 2 similairy named issues in the last hour

   — roughly 2 per similar-title cluster per hour, per account. It is not a
   throughput cap, and waiting does not help you go faster: a wave of 34
   identically-titled PRs drains at 2/hour (~17 hours) no matter how politely
   you back off. **The fix is to VARY THE TITLE, not to sleep.** Measured
   2026-07-17 on the 34-repo fed-resources wave: identical titles → 2 accepted,
   32 blocked; distinct titles → all 34 accepted in minutes, same token, same
   second. Backing off here paces for a limit that does not exist and converts a
   minutes-long wave into an overnight one.

2. **The volume cap — a REAL rate limit.** Separately, Forgejo caps the raw
   number of issues+PRs an account may open per rolling window (~25/hour). It
   blocks::

       "magogi-admin" posted 25 issues in under an hour: rate limited

   Here retitling does NOT help — the account has simply hit its hourly volume,
   whatever the titles. **The fix is to KEEP THE SAME TITLE, wait out the window
   (~10 min), and retry** — bounded, so a persistent cap eventually reports an
   error rather than hanging. Observed live at PR ~25 of the 2026-07-17 wave
   (window reset in ~30 min); it used to land in the generic error branch and
   error the wave's overflow instead of pacing it.

The similarity check is FUZZY. A shared phrase with a per-repo prefix still
clusters — ``mgf-auth: converge fed-resources`` and ``mgf-http: converge
fed-resources`` are "similar". Titles must differ in their *words*, not just in
one token. :func:`vary_titles` therefore rotates whole phrasings and only then
falls back to a per-repo qualifier.

Backoff (waiting) is the correct remedy for the REAL rate limits — HTTP 429 and
the volume cap above — and only those; it is never the answer to title
similarity.

Usage::

    pr_wave.py --branch chore/my-change --repos mgf-a mgf-b ... \\
               --body-file body.md --titles-file titles.txt [--dry-run]

``--titles-file``: one phrasing per line. They are assigned round-robin and
qualified per-repo when a wave is longer than the list, so a 34-repo wave off 6
phrasings still lands. Read the token from disk; never pass it on argv (it would
land in ``ps`` output and shell history).
"""

from __future__ import annotations

import argparse
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

API = "https://codeberg.org/api/v1"
OWNER = "magogi-admin"

#: Substring identifying the title-similarity block (the message is misspelled
#: upstream — match on the stable part, not the typo).
_SIMILAR_MARKER = "similairy named"
_SIMILAR_MARKER_ALT = "similarly named"

#: Substring identifying the VOLUME cap (the ~25 issues+PRs/hour limiter):
#: ``"magogi-admin" posted 25 issues in under an hour: rate limited``. Matched on
#: the stable ``posted`` + ``issues in under`` phrasing so the count (25) and the
#: window wording ("an hour"/"the hour"…) may drift without breaking detection.
_VOLUME_MARKER = "issues in under"
_VOLUME_MARKER_LEAD = "posted"


def is_title_throttle(message: str) -> bool:
    """True when ``message`` is the title-similarity block.

    Deliberately NOT treated as a rate limit: the remedy is a different title,
    not a wait. Matches both the upstream misspelling and a corrected spelling,
    so a Forgejo typo fix does not silently turn this back into a 17-hour wave.
    """
    low = message.lower()
    return _SIMILAR_MARKER in low or _SIMILAR_MARKER_ALT in low


def is_volume_cap(message: str) -> bool:
    """True when ``message`` is Forgejo's raw volume cap (~25 issues+PRs/hour).

    This one IS a real rate limit: the account has hit its hourly volume, so the
    remedy is to wait out the window and retry with the SAME title (retitling
    does nothing here). Matched on ``posted`` + ``issues in under`` to stay
    robust to the count and the exact window wording. The title-similarity block
    ("…similairy named issues in the last hour") is explicitly excluded so the
    two limiters never cross wires.
    """
    low = message.lower()
    if is_title_throttle(message):
        return False
    return _VOLUME_MARKER_LEAD in low and _VOLUME_MARKER in low


def vary_titles(repos: Sequence[str], phrasings: Sequence[str]) -> dict[str, str]:
    """Assign each repo a title that will not cluster with its neighbours.

    Rotates whole ``phrasings`` first — genuinely different words are what clears
    the throttle. Once the phrasings are exhausted the repo name is appended as a
    qualifier, which keeps a long wave moving; the rotation means two repos
    sharing a phrasing are far apart in the wave and differ by that qualifier.

    Raises when ``phrasings`` is empty — a wave with no titles is a wave that
    would take 17 hours, and failing loudly beats discovering that at 2/hour.
    """
    if not phrasings:
        raise ValueError("pr_wave: need at least one title phrasing (see --titles-file)")
    out: dict[str, str] = {}
    for i, repo in enumerate(repos):
        phrasing = phrasings[i % len(phrasings)]
        lap = i // len(phrasings)
        out[repo] = phrasing if lap == 0 else f"{repo}: {phrasing}"
    return out


def _post(url: str, payload: dict[str, Any], token: str) -> tuple[int, dict[str, Any]]:
    req = urllib.request.Request(
        url,
        data=json.dumps(payload).encode(),
        headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return resp.status, json.loads(resp.read() or b"{}")
    except urllib.error.HTTPError as exc:
        body = exc.read().decode(errors="replace")
        try:
            return exc.code, json.loads(body or "{}")
        except json.JSONDecodeError:
            return exc.code, {"message": body}


def open_pr(
    repo: str,
    head: str,
    base: str,
    title: str,
    body: str,
    token: str,
    post: Callable[[str, dict[str, Any], str], tuple[int, dict[str, Any]]] = _post,
) -> tuple[int | None, str]:
    """Open one PR. Returns ``(number, "")`` or ``(None, message)``."""
    status, data = post(
        f"{API}/repos/{OWNER}/{repo}/pulls",
        {"head": head, "base": base, "title": title, "body": body},
        token,
    )
    num = data.get("number")
    if status in (200, 201) and isinstance(num, int):
        return num, ""
    return None, str(data.get("message") or f"HTTP {status}")


#: How long to wait out the volume cap before retrying (seconds). The window is
#: ~1 hour but resets partially; 10 min per wait, bounded, drains a >25-PR
#: overflow without hanging a stuck wave forever.
_VOLUME_CAP_SLEEP = 600.0


def run_wave(
    repos: Sequence[str],
    head: str,
    base: str,
    body: str,
    phrasings: Sequence[str],
    token: str,
    post: Callable[[str, dict[str, Any], str], tuple[int, dict[str, Any]]] = _post,
    sleep: Callable[[float], None] = time.sleep,
    retitle_attempts: int = 3,
    volume_cap_waits: int = 4,
    volume_cap_sleep: float = _VOLUME_CAP_SLEEP,
) -> dict[str, int | str]:
    """Open a PR per repo. Returns ``{repo: number}`` or ``{repo: error}``.

    Three distinct failure modes, three distinct remedies:

    * **Title throttle** — **re-title and retry immediately** (no sleep): the
      block is about the words, so a wait accomplishes nothing. Bounded to
      ``retitle_attempts`` re-titles.
    * **Volume cap** (the ~25/hr limiter) — **keep the SAME title, sleep long
      (``volume_cap_sleep``), retry**: retitling does not help, only time does.
      Bounded to ``volume_cap_waits`` back-offs (separate budget from the
      re-titles) so a persistent cap eventually reports an error rather than
      hanging the wave.
    * **HTTP 429** — back off (short), because waiting IS the remedy there.

    Conflating the first with either of the latter two is what made a
    minutes-long wave take a night.
    """
    titles = vary_titles(repos, phrasings)
    results: dict[str, int | str] = {}
    for repo in repos:
        title = titles[repo]
        retitles = caps = backoffs = 0
        while True:
            num, err = open_pr(repo, head, base, title, body, token, post=post)
            if num is not None:
                results[repo] = num
                break
            if is_title_throttle(err):
                # NOT a rate limit — the words clustered. Re-title, do not sleep.
                retitles += 1
                if retitles >= retitle_attempts:
                    results[repo] = f"title throttle persisted after {retitle_attempts} re-titles"
                    break
                title = f"{repo} ({retitles + 1}): {titles[repo]}"
                continue
            if is_volume_cap(err):
                # A REAL rate limit — retitling is useless, only time helps. Keep
                # the same title, wait out the window, retry (bounded).
                caps += 1
                if caps > volume_cap_waits:
                    results[repo] = f"volume cap persisted after {volume_cap_waits} back-offs"
                    break
                sleep(volume_cap_sleep)
                continue
            if "429" in err or "too many requests" in err.lower():
                backoffs += 1
                if backoffs > volume_cap_waits:
                    results[repo] = f"HTTP 429 persisted after {volume_cap_waits} back-offs"
                    break
                sleep(60)
                continue
            results[repo] = err
            break
    return results


def _strip_token(raw: str) -> str:
    """Strip a ``key: value`` / ``key=value`` wrapper, else return the bare line."""
    return (
        raw.split(":", 1)[-1].split("=", 1)[-1].strip()
        if (":" in raw or "=" in raw)
        else raw.strip()
    )


def resolve_token(text: str, token_line: int | None) -> str:
    """Extract the API token from a token file's ``text``.

    Three paths (item 4.6 / CIR-06 — a mis-pointed file used to yield a
    wrong-but-nonempty token and a confusing 401 wave):

    * **explicit** ``token_line`` given: read exactly that 1-based line — fully
      backward compatible with the historical default-of-11 behaviour.
    * **single-value file** (exactly one non-empty line) and no explicit
      ``--token-line``: use it — the ``my_stuff/`` convention.
    * **multi-line file** and no explicit ``--token-line``: refuse loudly. Guessing
      a line yields a nonempty-but-invalid token and a whole wave of 401s.

    Raises :class:`ValueError` with an actionable message on ambiguity / range.
    """
    if token_line is not None:
        lines = text.splitlines()
        if not 1 <= token_line <= len(lines):
            raise ValueError(
                f"pr_wave: --token-line {token_line} is out of range "
                f"(token file has {len(lines)} line(s))"
            )
        return _strip_token(lines[token_line - 1])

    nonempty = [ln for ln in text.splitlines() if ln.strip()]
    if not nonempty:
        raise ValueError("pr_wave: token file has no non-empty lines")
    if len(nonempty) > 1:
        raise ValueError(
            f"pr_wave: token file has {len(nonempty)} non-empty lines and no "
            "--token-line was given. Refusing to guess: a wrong line yields a "
            "nonempty-but-invalid token and a whole wave of 401s. Either pass "
            "--token-line N to select the line explicitly, or point --token-file "
            "at a single-value token file (the my_stuff/ convention)."
        )
    return _strip_token(nonempty[0])


def main(argv: Sequence[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    ap.add_argument("--branch", required=True, help="head branch (must already be pushed)")
    ap.add_argument("--base", default="main")
    ap.add_argument("--repos", nargs="+", required=True)
    ap.add_argument("--body-file", type=Path, required=True)
    ap.add_argument("--titles-file", type=Path, required=True, help="one phrasing per line")
    ap.add_argument(
        "--token-file",
        type=Path,
        required=True,
        help="file holding the API token (never pass a token on argv — it lands in ps + history)",
    )
    ap.add_argument(
        "--token-line",
        type=int,
        default=None,
        help="1-based line to read the token from. Default: use the file's single "
        "non-empty line; a multi-line file without this flag is an error (no guessing).",
    )
    ap.add_argument("--dry-run", action="store_true", help="print the assigned titles and exit")
    args = ap.parse_args(argv)

    phrasings = [ln.strip() for ln in args.titles_file.read_text().splitlines() if ln.strip()]
    if args.dry_run:
        for repo, title in vary_titles(args.repos, phrasings).items():
            print(f"{repo:24} {title}")
        return 0

    try:
        token = resolve_token(args.token_file.read_text(), args.token_line)
    except ValueError as exc:
        print(exc, file=sys.stderr)
        return 2

    results = run_wave(
        args.repos, args.branch, args.base, args.body_file.read_text(), phrasings, token
    )
    ok = {r: v for r, v in results.items() if isinstance(v, int)}
    bad = {r: v for r, v in results.items() if not isinstance(v, int)}
    for repo, num in ok.items():
        print(f"  {repo:24} #{num}")
    for repo, err in bad.items():
        print(f"  {repo:24} ERROR: {err}", file=sys.stderr)
    print(f"\nopened {len(ok)}/{len(args.repos)}")
    return 1 if bad else 0


if __name__ == "__main__":
    raise SystemExit(main())
