"""``mgf-fed ci-stats`` — the first standing CI-health metric (CIR-11).

Before this verb the fleet's CI health was an anecdote: the 2026-07-17 CI Support Review had to
measure a 21.7% red rate with throwaway scratchpad scripts because *no standing metric existed*, so
neither regressions nor wins (like the SSH-clone fix that erased 66 clone-flake reds) could be seen
in numbers. This productizes that measurement so the numbers are **reproducible**.

Methodology (matches the review's exactly — poll **Woodpecker only**, never Codeberg — PAPER-110):

- **Sample:** the last ``--limit`` pipelines per repo (default 40, the review's
  ``last-40-per-repo``) across the registry roster (``--repo`` to restrict).
- **Red rate:** ``(failure + error + killed) / total`` over the sample.
- **Red by failed-step class:** each red pipeline is classed by *why* it went red — a ``failure``
  by its first failed step's name (``clone`` when clone-only, else the earliest failed step),
  an ``error`` by whether its message carries the transient forge config-fetch signature
  (CIR-10) or not, and ``killed`` as its own class. Failure step names come from the pipeline
  detail (one extra fetch per red pipeline; the 429-budget helper paces them).
- **Restart tax:** ``commit + event`` pairs that ran more than one pipeline — the cost of every
  re-push and every ``ci-heal``/manual restart. Reported as pair-count and total *extra* runs.
- **Durations:** wall time ``finished - started`` per pipeline; p50/p90 by **nearest-rank**
  (rank = ``ceil(p/100 * N)``) over pipelines with usable timing — deterministic, no interpolation.
- **Per-repo outliers:** repos whose red rate exceeds the fleet's (with >=2 reds, so a lone 1/1 is
  not paraded as a 100% outlier).

``--json`` emits the whole computation for a weekly cron to post where the founder reads it.
Pure stdlib; every network call rides :func:`mgf.fed._http.request_json` (PAPER-119 429 budget).
"""

from __future__ import annotations

import json
import math
import os
import urllib.error
from collections import Counter
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING, Any

from mgf.fed._http import request_json
from mgf.fed.cli._ci_heal import _CONFIG_FETCH_SIG, _error_texts, _steps
from mgf.fed.cli._ping import _DEFAULT_OWNER, _DEFAULT_WP_URL, _token

if TYPE_CHECKING:
    import argparse

# The review's methodology sampled the last 40 pipelines per repo; keep that the default so the
# numbers are comparable run-to-run (and to the review's baseline).
_DEFAULT_LIMIT = 40
_RED_STATES = frozenset({"failure", "error", "killed"})
_FAILED_STATES = frozenset({"failure", "error"})


def add_ci_stats_args(parser: argparse.ArgumentParser) -> None:
    """Register the ``ci-stats`` subcommand's arguments."""
    parser.add_argument(
        "--repo",
        action="append",
        default=None,
        help="repo to sample (repeatable; default: every registry member)",
    )
    parser.add_argument(
        "--owner", default=_DEFAULT_OWNER, help=f"forge owner (default {_DEFAULT_OWNER})"
    )
    parser.add_argument(
        "--url",
        default=os.environ.get("WOODPECKER_URL", _DEFAULT_WP_URL),
        help="Woodpecker base URL",
    )
    parser.add_argument(
        "--token", default=None, help="WP API token (else $WOODPECKER_TOKEN / my_stuff file)"
    )
    parser.add_argument(
        "--limit",
        type=int,
        default=_DEFAULT_LIMIT,
        help=f"recent pipelines sampled per repo (default {_DEFAULT_LIMIT}, the review method)",
    )
    parser.add_argument("--json", action="store_true", help="emit the full computation as JSON")


# --------------------------------------------------------------------------- data model


@dataclass(frozen=True, slots=True)
class PipelineRec:
    """One sampled pipeline, reduced to the fields the metrics need."""

    repo: str
    number: int
    status: str
    commit: str
    event: str
    duration: int | None  # seconds (finished - started); None if not usably timed
    failed_steps: tuple[str, ...] = ()  # failure step names (root-first); empty unless a failure
    error_text: str = ""  # lowercased error message(s); populated for error-status pipelines


def _duration(pipe: dict[str, Any]) -> int | None:
    """Wall time from the pipeline's ``started``/``finished`` epoch seconds, or None if either is
    absent/zero or the clock ran backwards (a not-yet-finished or malformed record)."""
    try:
        started = int(pipe.get("started") or pipe.get("started_at") or 0)
        finished = int(pipe.get("finished") or pipe.get("finished_at") or 0)
    except (TypeError, ValueError):
        return None
    if started <= 0 or finished <= 0 or finished < started:
        return None
    return finished - started


def _error_text(obj: dict[str, Any]) -> str:
    """Lowercased, newline-joined error messages from a pipeline object (list or detail).

    Reuses ci-heal's shape-tolerant extractor so the two verbs read errors identically."""
    return "\n".join(_error_texts(obj)).lower()


def classify_red(rec: PipelineRec) -> str | None:
    """The failed-step class for a red pipeline (None for a green/neutral one).

    - ``killed`` → ``"killed"``
    - ``error`` → ``"error: forge config-fetch"`` (the CIR-10 transient signature) or ``"error"``
    - ``failure`` → ``"clone"`` when clone-only, else the earliest failed step; ``"failure"`` when
      no step detail was available.
    """
    if rec.status not in _RED_STATES:
        return None
    if rec.status == "killed":
        return "killed"
    if rec.status == "error":
        return "error: forge config-fetch" if _CONFIG_FETCH_SIG in rec.error_text else "error"
    if not rec.failed_steps:
        return "failure"
    if all(s == "clone" for s in rec.failed_steps):
        return "clone"
    return rec.failed_steps[0]


def percentile(values: list[int], p: float) -> int | None:
    """Nearest-rank percentile (rank = ``ceil(p/100 * N)``, 1-indexed) — deterministic, no
    interpolation, so a given sample always yields the same number."""
    if not values:
        return None
    ordered = sorted(values)
    rank = max(1, math.ceil(p / 100.0 * len(ordered)))
    return ordered[min(rank, len(ordered)) - 1]


# --------------------------------------------------------------------------- computation


@dataclass(frozen=True, slots=True)
class RepoStat:
    repo: str
    total: int
    red: int
    red_rate: float  # 0..1


@dataclass(frozen=True, slots=True)
class FleetStats:
    total: int
    red: int
    red_rate: float  # 0..1
    by_status: dict[str, int]
    red_by_class: dict[str, int]
    restart_pairs: int  # commit+event pairs that ran >1 pipeline
    restart_extra: int  # total extra runs beyond the first, over those pairs
    duration_p50: int | None
    duration_p90: int | None
    duration_n: int  # pipelines with usable timing
    per_repo: list[RepoStat] = field(default_factory=list)
    outliers: list[RepoStat] = field(default_factory=list)


def compute(recs: list[PipelineRec]) -> FleetStats:
    """Reduce sampled pipelines to the fleet + per-repo metrics (pure — no I/O)."""
    total = len(recs)
    by_status = Counter(r.status for r in recs)
    red = sum(by_status.get(s, 0) for s in _RED_STATES)
    red_by_class: Counter[str] = Counter()
    for r in recs:
        cls = classify_red(r)
        if cls is not None:
            red_by_class[cls] += 1

    # Restart tax: group by (commit, event); a group of >1 means the same commit+event ran more
    # than once — a re-push, a heal, or a manual restart.
    runs: Counter[tuple[str, str]] = Counter((r.commit, r.event) for r in recs if r.commit)
    restart_pairs = sum(1 for n in runs.values() if n > 1)
    restart_extra = sum(n - 1 for n in runs.values() if n > 1)

    durations = [r.duration for r in recs if r.duration is not None]

    # Per-repo red rate.
    per_repo_totals: Counter[str] = Counter(r.repo for r in recs)
    per_repo_reds: Counter[str] = Counter(r.repo for r in recs if r.status in _RED_STATES)
    per_repo = [
        RepoStat(
            repo=repo,
            total=t,
            red=per_repo_reds.get(repo, 0),
            red_rate=(per_repo_reds.get(repo, 0) / t if t else 0.0),
        )
        for repo, t in sorted(per_repo_totals.items())
    ]
    fleet_rate = red / total if total else 0.0
    outliers = sorted(
        (rs for rs in per_repo if rs.red >= 2 and rs.red_rate > fleet_rate),
        key=lambda rs: (-rs.red_rate, -rs.red, rs.repo),
    )

    return FleetStats(
        total=total,
        red=red,
        red_rate=fleet_rate,
        by_status=dict(sorted(by_status.items(), key=lambda kv: (-kv[1], kv[0]))),
        red_by_class=dict(sorted(red_by_class.items(), key=lambda kv: (-kv[1], kv[0]))),
        restart_pairs=restart_pairs,
        restart_extra=restart_extra,
        duration_p50=percentile(durations, 50),
        duration_p90=percentile(durations, 90),
        duration_n=len(durations),
        per_repo=sorted(per_repo, key=lambda rs: (-rs.red_rate, -rs.total, rs.repo)),
        outliers=outliers,
    )


# --------------------------------------------------------------------------- collection (I/O)


def _lookup_id(base: str, owner: str, repo: str, token: str) -> tuple[int | None, str | None]:
    """(repo_id, warning) — the Woodpecker numeric id, or a warning line when it can't be had."""
    try:
        info = request_json(f"{base}/api/repos/lookup/{owner}/{repo}", token=token)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return None, f"SKIP  {repo}: not activated on Woodpecker (404)"
        if e.code == 401:
            return None, f"AUTH  {repo}: 401 — token expired; mint per MGF_INFRA §2"
        return None, f"FAIL  {repo}: lookup HTTP {e.code}"
    except urllib.error.URLError as e:
        return None, f"FAIL  {repo}: lookup unreachable ({e.reason})"
    repo_id = info.get("id")
    if not repo_id:
        return None, f"FAIL  {repo}: lookup returned no id"
    return int(repo_id), None


def collect_repo(
    base: str, owner: str, repo: str, token: str, limit: int
) -> tuple[list[PipelineRec], list[str]]:
    """Sample one repo's last ``limit`` pipelines into records (+ any warning lines).

    Fetches per-pipeline detail only for reds — to name the failed step(s) — so a green fleet
    costs one list call per repo.
    """
    repo_id, warn = _lookup_id(base, owner, repo, token)
    if repo_id is None:
        return [], [warn] if warn else []
    try:
        listing = request_json(f"{base}/api/repos/{repo_id}/pipelines?perPage={limit}", token=token)
    except (urllib.error.HTTPError, urllib.error.URLError) as e:
        return [], [f"FAIL  {repo}: pipeline list failed ({e})"]
    pipelines: list[dict[str, Any]] = (
        listing if isinstance(listing, list) else listing.get("pipelines", [])
    )

    recs: list[PipelineRec] = []
    warnings: list[str] = []
    for pipe in pipelines:
        status = str(pipe.get("status", ""))
        number = pipe.get("number")
        if number is None:
            continue
        failed_steps: tuple[str, ...] = ()
        error_text = _error_text(pipe)
        if status == "failure":
            try:
                detail = request_json(f"{base}/api/repos/{repo_id}/pipelines/{number}", token=token)
            except (urllib.error.HTTPError, urllib.error.URLError) as e:
                warnings.append(
                    f"WARN  {repo}#{number}: detail fetch failed ({e}); classed 'failure'"
                )
                detail = {}
            failed_steps = tuple(
                str(s.get("name", "")) for s in _steps(detail) if s.get("state") in _FAILED_STATES
            )
        elif status == "error" and not error_text:
            # Error-status pipelines run zero steps; the message sometimes only lands on the detail.
            try:
                detail = request_json(f"{base}/api/repos/{repo_id}/pipelines/{number}", token=token)
                error_text = _error_text(detail)
            except (urllib.error.HTTPError, urllib.error.URLError):
                pass
        recs.append(
            PipelineRec(
                repo=repo,
                number=int(number),
                status=status,
                commit=str(pipe.get("commit", "")),
                event=str(pipe.get("event", "")),
                duration=_duration(pipe),
                failed_steps=failed_steps,
                error_text=error_text,
            )
        )
    return recs, warnings


# --------------------------------------------------------------------------- rendering


def _pct(x: float) -> str:
    return f"{x * 100:.1f}%"


def render(stats: FleetStats, *, limit: int, n_repos: int, url: str) -> str:
    lines = [
        f"mgf-fed ci-stats — last {limit} pipeline(s)/repo across {n_repos} repo(s) on {url}",
        "  (Woodpecker only; red rate = (failure+error+killed)/total; durations nearest-rank)",
        "",
        f"fleet: {stats.total} pipeline(s) · {stats.total - stats.red} green / {stats.red} red "
        f"· red rate {_pct(stats.red_rate)}",
    ]
    if stats.by_status:
        lines.append("  by status:   " + "  ".join(f"{s} {n}" for s, n in stats.by_status.items()))
    if stats.red_by_class:
        lines.append(
            "  red by class:" + "".join(f"  {cls} {n}" for cls, n in stats.red_by_class.items())
        )
    lines.append(
        f"  restart tax: {stats.restart_pairs} commit+event pair(s) ran >1 pipeline "
        f"(+{stats.restart_extra} extra run(s))"
    )
    if stats.duration_n:
        lines.append(
            f"  duration:    p50 {stats.duration_p50}s  p90 {stats.duration_p90}s  "
            f"(N timed = {stats.duration_n})"
        )
    else:
        lines.append("  duration:    (no usably-timed pipelines in the sample)")
    lines.append("")
    if stats.outliers:
        lines.append(f"per-repo outliers (red rate above fleet {_pct(stats.red_rate)}, >=2 reds):")
        for rs in stats.outliers:
            lines.append(
                f"  {rs.repo:<24} {rs.total:>3} run(s)  {rs.red:>3} red  {_pct(rs.red_rate)}"
            )
    else:
        lines.append("per-repo outliers: none (no repo above the fleet red rate with >=2 reds)")
    return "\n".join(lines)


def run_ci_stats(args: argparse.Namespace) -> int:
    """Sample the roster's recent pipelines and report fleet CI health (the CIR-11 baseline)."""
    repos: list[str] = list(args.repo or [])
    if not repos:
        from mgf.fed._roster import members

        repos = [m.repo for m in members()]

    token = _token(getattr(args, "token", None))
    if not token:
        print(
            "ci-stats: no token — set $WOODPECKER_TOKEN or "
            "mgf-standard/my_stuff/woodpecker_token.txt (mint per MGF_INFRA §2)."
        )
        return 2

    all_recs: list[PipelineRec] = []
    warnings: list[str] = []
    for repo in repos:
        recs, warns = collect_repo(args.url, args.owner, repo, token, args.limit)
        all_recs.extend(recs)
        warnings.extend(warns)

    stats = compute(all_recs)

    if getattr(args, "json", False):
        payload = {
            "sampled_repos": len(repos),
            "limit": args.limit,
            "url": args.url,
            "warnings": warnings,
            "stats": asdict(stats),
        }
        print(json.dumps(payload, indent=2, sort_keys=True))
        return 0

    for w in warnings:
        print(f"  {w}")
    print(render(stats, limit=args.limit, n_repos=len(repos), url=args.url))
    return 0


__all__ = [
    "FleetStats",
    "PipelineRec",
    "RepoStat",
    "add_ci_stats_args",
    "classify_red",
    "collect_repo",
    "compute",
    "percentile",
    "render",
    "run_ci_stats",
]
