"""``mgf-fed pr-health`` — is an open PR actually reviewable?

Two board findings, one question. The forge answers *"can this merge?"* and CI
answers *"is it green?"*, and **both can be true of a pull request that contains
nothing at all**.

* **Item 34 — the empty PR.** Six `chore/scaffold-wave-0179` PRs carried
  ``changed_files: 0``, ``+0/-0``, and the forge reported ``mergeable: true``
  *and* CI reported ``success`` — because there was nothing in them to fail. One
  green pipeline ran **2h27m after its own content was already on main**. Each
  was a second PR opened from a branch an earlier PR had already merged. A
  reviewer reading the board saw "green and mergeable" and had no way to tell
  those apart from ready work.
* **Item 33 — the stale PR.** Nothing measures how far an open PR has fallen
  behind its base, so a PR left open long enough goes red for reasons unrelated
  to its content, and the red is then re-diagnosed from scratch.

Both are one API call per PR away from being knowable, and neither had an exit
code. This gives them one.

Exit status: ``0`` every open PR is substantive and current · ``1`` at least one
is empty or stale · ``2`` the check could not run (no token, forge unreachable,
no roster) — never ``0``, because "I could not look" must not read as "I looked
and it was fine".

Environment: ``AGENT_CODEBERG_WRITE`` (the declared credential; see
``mgf-secrets`` — its ``consumers`` field names agent sessions). Routed through
:func:`mgf.fed._http.request_json`, so a 429 gets bounded backoff rather than a
retry-hammer (PAPER-119).
"""

from __future__ import annotations

import os
import urllib.error
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mgf.fed._http import request_json
from mgf.fed._roster import members

if TYPE_CHECKING:
    import argparse

_API = "https://codeberg.org/api/v1"
_DEFAULT_OWNER = "magogi-admin"
_TOKEN_ENV = "AGENT_CODEBERG_WRITE"  # noqa: S105 — the env-var NAME, not a value

#: How far behind its base a PR may fall before it is called stale. Chosen
#: because the observed failures were PRs tens of commits behind, not one or two.
DEFAULT_BEHIND_LIMIT = 20


@dataclass(frozen=True, slots=True)
class PRFinding:
    repo: str
    number: int
    title: str
    kind: str  # "empty" / "stale"
    detail: str

    def __str__(self) -> str:
        return f"{self.repo}#{self.number}  {self.kind.upper():<6} {self.detail} — {self.title}"


def add_pr_health_args(parser: argparse.ArgumentParser) -> None:
    """Register the ``pr-health`` subcommand's arguments."""
    parser.add_argument(
        "--owner", default=_DEFAULT_OWNER, help=f"forge owner (default {_DEFAULT_OWNER})"
    )
    parser.add_argument(
        "--behind-limit",
        type=int,
        default=DEFAULT_BEHIND_LIMIT,
        help=f"commits behind base before a PR is called stale (default {DEFAULT_BEHIND_LIMIT})",
    )
    parser.add_argument("--repo", default=None, help="check one repo instead of the whole roster")
    parser.add_argument("--root", type=Path, default=None, help="federation root")


def _token() -> str | None:
    return os.environ.get(_TOKEN_ENV) or None


def _behind_by(owner: str, repo: str, base: str, head: str, token: str) -> int | None:
    """Commits on ``base`` that are not on ``head``. None when it cannot be read.

    None is distinct from 0 on purpose: 0 means "current", None means "I could
    not tell", and those must not collapse into the same report.
    """
    try:
        data: dict[str, Any] = request_json(
            f"{_API}/repos/{owner}/{repo}/compare/{head}...{base}", token=token, timeout=30
        )
    except (urllib.error.HTTPError, urllib.error.URLError, ValueError):
        return None
    total = data.get("total_commits")
    if isinstance(total, int):
        return total
    commits = data.get("commits")
    return len(commits) if isinstance(commits, list) else None


def check_repo(owner: str, repo: str, token: str, behind_limit: int) -> tuple[list[PRFinding], str]:
    """(findings, error). A non-empty error means this repo could not be checked."""
    try:
        raw = request_json(
            f"{_API}/repos/{owner}/{repo}/pulls?state=open&limit=50", token=token, timeout=30
        )
    except urllib.error.HTTPError as exc:
        return [], f"{repo}: HTTP {exc.code}"
    except (urllib.error.URLError, ValueError) as exc:
        return [], f"{repo}: unreachable ({exc})"

    prs: list[dict[str, Any]] = raw if isinstance(raw, list) else []
    found: list[PRFinding] = []
    for pr in prs:
        num = int(pr.get("number", 0))
        title = str(pr.get("title", ""))[:70]
        changed = pr.get("changed_files")
        # `changed_files` is absent from the LIST response on some forge
        # versions; absent must not read as zero, so ask for the PR directly.
        if changed is None:
            try:
                changed = request_json(
                    f"{_API}/repos/{owner}/{repo}/pulls/{num}", token=token, timeout=30
                ).get("changed_files")
            except (urllib.error.HTTPError, urllib.error.URLError, ValueError):
                changed = None
        if changed == 0:
            found.append(
                PRFinding(
                    repo,
                    num,
                    title,
                    "empty",
                    "changed_files: 0 — mergeable and green because it contains nothing",
                )
            )
            continue  # an empty PR cannot also be meaningfully stale

        base = str((pr.get("base") or {}).get("ref") or "")
        head = str((pr.get("head") or {}).get("ref") or "")
        if not base or not head:
            continue
        behind = _behind_by(owner, repo, base, head, token)
        if behind is not None and behind > behind_limit:
            found.append(
                PRFinding(
                    repo,
                    num,
                    title,
                    "stale",
                    f"{behind} commits behind {base} (limit {behind_limit})",
                )
            )
    return found, ""


def run_pr_health(args: argparse.Namespace) -> int:
    """``mgf-fed pr-health``. 0 clean · 1 findings · 2 could not run."""
    token = _token()
    if not token:
        print(
            f"pr-health: no ${_TOKEN_ENV} in the environment — nothing checked, "
            "which is NOT a pass. Run under "
            "`mgf-secrets exec --with agent_codeberg_write -- …`."
        )
        return 2

    owner = getattr(args, "owner", _DEFAULT_OWNER)
    limit = getattr(args, "behind_limit", DEFAULT_BEHIND_LIMIT)
    one = getattr(args, "repo", None)
    if one:
        repos = [one]
    else:
        try:
            repos = sorted({m.repo for m in members(getattr(args, "root", None))})
        except (FileNotFoundError, OSError) as exc:
            print(f"pr-health: cannot read the registry roster ({exc}) — nothing checked.")
            return 2
    if not repos:
        print("pr-health: the roster resolved empty — nothing checked, which is NOT a pass.")
        return 2

    findings: list[PRFinding] = []
    errors: list[str] = []
    for repo in repos:
        hits, err = check_repo(owner, repo, token, limit)
        findings.extend(hits)
        if err:
            errors.append(err)

    for finding in findings:
        print(f"  {finding}")
    for e in errors:
        print(f"  ! {e}")

    checked = len(repos) - len(errors)
    print(f"\npr-health: {len(findings)} finding(s) across {checked}/{len(repos)} repo(s).")
    if errors:
        print(f"{len(errors)} repo(s) could not be checked — that is not a pass either.")
        return 2
    return 1 if findings else 0


__all__ = [
    "DEFAULT_BEHIND_LIMIT",
    "PRFinding",
    "add_pr_health_args",
    "check_repo",
    "run_pr_health",
]
