"""`scripts/pr_wave.py` — the title-similarity throttle is not a rate limit.

The behaviour under test is the one that cost a wave a night: Forgejo blocks
"2 similairy named issues in the last hour" — an anti-spam rule on TITLE
SIMILARITY, not a cap on PR creation. The remedy is a different title; sleeping
accomplishes nothing. These tests pin that the wave engine re-titles rather than
backs off, and that it still backs off for the one thing that IS a rate limit.
"""

from __future__ import annotations

import importlib.util
import sys
from pathlib import Path
from typing import Any

import pytest

_SPEC = importlib.util.spec_from_file_location(
    "pr_wave", Path(__file__).resolve().parents[2] / "scripts" / "pr_wave.py"
)
assert _SPEC and _SPEC.loader
pr_wave = importlib.util.module_from_spec(_SPEC)
sys.modules["pr_wave"] = pr_wave
_SPEC.loader.exec_module(pr_wave)

THROTTLE = '"magogi-admin" posted 2 similairy named issues in the last hour: rate limited'
#: Forgejo's SECOND limiter — the raw ~25/hr volume cap (observed live 2026-07-17).
VOLUME_CAP = '"magogi-admin" posted 25 issues in under an hour: rate limited'
PHRASINGS = ["regenerate the graph", "rebuild generated state", "refresh the projection"]


def test_the_throttle_is_recognised_including_the_upstream_typo() -> None:
    assert pr_wave.is_title_throttle(THROTTLE)
    # a corrected upstream spelling must not silently turn this into a 17-hour wave
    assert pr_wave.is_title_throttle("posted 2 similarly named issues in the last hour")
    assert not pr_wave.is_title_throttle("Branch already exists")


def test_the_two_limiters_never_cross_wires() -> None:
    """The volume cap and the title throttle are OPPOSITE remedies — a message that
    is one must never read as the other, or the wave takes the wrong action."""
    assert pr_wave.is_volume_cap(VOLUME_CAP)
    # the TITLE throttle must NOT register as the volume cap (it, too, says "posted")
    assert not pr_wave.is_volume_cap(THROTTLE)
    assert not pr_wave.is_title_throttle(VOLUME_CAP)
    # robust to count and window-wording drift
    assert pr_wave.is_volume_cap('"magogi-admin" posted 40 issues in under the hour')
    assert not pr_wave.is_volume_cap("Branch already exists")


def test_titles_are_distinct_across_the_wave() -> None:
    """Identical titles are the whole bug: 2 land per hour and the rest block."""
    repos = [f"mgf-{i}" for i in range(34)]
    titles = pr_wave.vary_titles(repos, PHRASINGS)
    assert len(set(titles.values())) == len(repos), "a repeated title re-creates the throttle"


def test_first_lap_uses_whole_phrasings_not_a_shared_stem() -> None:
    """The similarity check is FUZZY: a shared phrase with a per-repo prefix still
    clusters, so the first repos must differ in their WORDS."""
    titles = pr_wave.vary_titles(["mgf-a", "mgf-b", "mgf-c"], PHRASINGS)
    assert sorted(titles.values()) == sorted(PHRASINGS)


def test_empty_phrasings_fails_loudly() -> None:
    with pytest.raises(ValueError, match="at least one title phrasing"):
        pr_wave.vary_titles(["mgf-a"], [])


def _poster(script: list[tuple[int, dict[str, Any]]]) -> Any:
    calls: list[dict[str, Any]] = []

    def post(url: str, payload: dict[str, Any], token: str) -> tuple[int, dict[str, Any]]:
        calls.append(payload)
        return script[min(len(calls) - 1, len(script) - 1)]

    post.calls = calls  # type: ignore[attr-defined]
    return post


def test_a_throttled_pr_is_RE_TITLED_and_retried_without_sleeping() -> None:
    """THE POINT. Blocked on title similarity -> change the words, immediately.
    Sleeping here paces for a limit that does not exist."""
    post = _poster([(422, {"message": THROTTLE}), (201, {"number": 7})])
    slept: list[float] = []
    out = pr_wave.run_wave(
        ["mgf-a"], "br", "main", "body", PHRASINGS, "tok", post=post, sleep=slept.append
    )
    assert out == {"mgf-a": 7}
    assert slept == [], "a title throttle must never sleep — the remedy is a different title"
    assert post.calls[0]["title"] != post.calls[1]["title"], "it must actually re-title"


def test_a_real_429_DOES_back_off() -> None:
    """The one case where waiting is the correct remedy — don't throw the baby out."""
    post = _poster([(429, {"message": "too many requests"}), (201, {"number": 9})])
    slept: list[float] = []
    out = pr_wave.run_wave(
        ["mgf-a"], "br", "main", "body", PHRASINGS, "tok", post=post, sleep=slept.append
    )
    assert out == {"mgf-a": 9}
    assert slept, "a genuine 429 must back off"


def test_a_wave_of_many_repos_opens_every_pr() -> None:
    post = _poster([(201, {"number": 1})])
    repos = [f"mgf-{i}" for i in range(34)]
    out = pr_wave.run_wave(repos, "br", "main", "body", PHRASINGS, "tok", post=post)
    assert len(out) == 34
    assert all(isinstance(v, int) for v in out.values())
    assert len({c["title"] for c in post.calls}) == 34


def test_a_non_throttle_error_is_reported_not_retried_forever() -> None:
    post = _poster([(409, {"message": "head branch does not exist"})])
    out = pr_wave.run_wave(["mgf-a"], "br", "main", "body", PHRASINGS, "tok", post=post)
    assert "head branch does not exist" in str(out["mgf-a"])
    assert len(post.calls) == 1, "a real error must not be retried as if it were a throttle"


def test_a_volume_cap_backs_off_with_the_SAME_title_then_succeeds() -> None:
    """THE 0.4 POINT. The volume cap IS a real rate limit: wait it out. Retitling
    does nothing (the account hit its hourly volume), so the title must NOT change."""
    post = _poster([(422, {"message": VOLUME_CAP}), (201, {"number": 25})])
    slept: list[float] = []
    out = pr_wave.run_wave(
        ["mgf-a"], "br", "main", "body", PHRASINGS, "tok", post=post, sleep=slept.append
    )
    assert out == {"mgf-a": 25}
    assert len(slept) == 1, "the volume cap must back off exactly once here"
    assert slept[0] >= 300, "a volume-cap wait is long (~10 min), not the 429 short wait"
    assert post.calls[0]["title"] == post.calls[1]["title"], (
        "retitling does not clear the volume cap — the same title must be retried"
    )


def test_a_persistent_volume_cap_is_bounded_and_eventually_reported() -> None:
    """A cap that never lifts must not hang the wave forever — bounded back-offs,
    then a reported error, all on the SAME title."""
    post = _poster([(422, {"message": VOLUME_CAP})])  # every call caps
    slept: list[float] = []
    out = pr_wave.run_wave(
        ["mgf-a"],
        "br",
        "main",
        "body",
        PHRASINGS,
        "tok",
        post=post,
        sleep=slept.append,
        volume_cap_waits=3,
    )
    assert "volume cap persisted" in str(out["mgf-a"])
    assert len(slept) == 3, "exactly volume_cap_waits back-offs before giving up"
    assert len({c["title"] for c in post.calls}) == 1, "same title across every retry"


def test_volume_cap_and_retitle_budgets_are_independent() -> None:
    """A volume cap does NOT consume the retitle budget, nor vice-versa: after
    waiting out a cap, a title throttle still gets its full re-title chances."""
    post = _poster(
        [(422, {"message": VOLUME_CAP}), (422, {"message": THROTTLE}), (201, {"number": 3})]
    )
    slept: list[float] = []
    out = pr_wave.run_wave(
        ["mgf-a"], "br", "main", "body", PHRASINGS, "tok", post=post, sleep=slept.append
    )
    assert out == {"mgf-a": 3}
    assert len(slept) == 1, "one long wait for the cap; the title throttle never sleeps"


def test_resolve_token_single_value_file_is_used_without_a_line_flag() -> None:
    """The my_stuff/ convention: a one-value file just works, no --token-line."""
    assert pr_wave.resolve_token("abc123\n", None) == "abc123"
    # a key: value / key=value wrapper on the single line is still stripped
    assert pr_wave.resolve_token("token: abc123\n", None) == "abc123"
    assert pr_wave.resolve_token("codeberg=abc123", None) == "abc123"
    # surrounding blank lines don't count as content
    assert pr_wave.resolve_token("\n\n  abc123  \n\n", None) == "abc123"


def test_resolve_token_multi_line_without_an_explicit_line_is_a_loud_error() -> None:
    """CIR-06: a mis-pointed multi-line file used to yield a wrong-but-nonempty
    token and a 401 wave — now it refuses loudly instead of guessing."""
    text = "# my tokens\nwoodpecker: aaa\ncodeberg: bbb\n"
    with pytest.raises(ValueError, match="Refusing to guess"):
        pr_wave.resolve_token(text, None)


def test_resolve_token_explicit_line_still_works_exactly_as_before() -> None:
    """Backward compatible: an explicit --token-line reads that 1-based line and
    strips the wrapper, exactly as the old default-of-11 code path did."""
    text = "# header\nline2\ncodeberg=deadbeef\nline4\n"
    assert pr_wave.resolve_token(text, 3) == "deadbeef"
    # an out-of-range line is a clear error, not a raw IndexError
    with pytest.raises(ValueError, match="out of range"):
        pr_wave.resolve_token(text, 99)
