"""Authoritative terminal-transition cleanup for ``status:pr-open`` (#780).

``status:pr-open`` is applied by ``gitea_create_pr`` while a linked pull
request is open. Nothing removed it again: the workflow's terminal paths
(merge, close-without-merge, supersession, already-landed reconciliation,
controller closure) each ended without touching the label, so a repository
audit found 40 closed issues still carrying it.

This module is the single source of truth for that cleanup. Every sanctioned
terminal path plans its label mutation here rather than implementing its own
rule, so the paths cannot drift apart:

- :func:`plan_pr_open_cleanup` decides the exact resulting label set. It only
  ever removes ``status:pr-open``; every other label is preserved verbatim,
  including the case where the result is an empty label set.
- :func:`verify_pr_open_cleanup` is the read-after-write check. It proves the
  label is gone *and* that no unrelated label was dropped or added.
- :func:`detect_residual_pr_open` is the terminal validation: given issues, it
  reports any that still carry the label, so a controller closure or audit
  fails loudly instead of leaving the leak behind.

The rule is idempotent by construction: an issue without the label plans no
mutation, so retries and recovery re-runs are harmless.

This module performs no I/O — callers own the Gitea API calls.
"""

from __future__ import annotations

from typing import Any, Iterable, Mapping, Sequence

import issue_workflow_labels

#: The single label this module is responsible for retiring.
PR_OPEN_LABEL = "status:pr-open"

# Canonical terminal reasons — the sanctioned ways an issue can end up
# associated with a pull request that is no longer open.
MERGED = "merged"
CLOSED_WITHOUT_MERGE = "closed_without_merge"
SUPERSEDED = "superseded"
ALREADY_LANDED = "already_landed"
CONTROLLER_CLOSURE = "controller_closure"
ABANDONED = "abandoned"
RETRY_RECOVERY = "retry_recovery"

TERMINAL_REASONS: tuple[str, ...] = (
    MERGED,
    CLOSED_WITHOUT_MERGE,
    SUPERSEDED,
    ALREADY_LANDED,
    CONTROLLER_CLOSURE,
    ABANDONED,
    RETRY_RECOVERY,
)

_REASON_ALIASES: dict[str, str] = {
    "merge": MERGED,
    "merged": MERGED,
    "pr_merged": MERGED,
    "closed": CLOSED_WITHOUT_MERGE,
    "close": CLOSED_WITHOUT_MERGE,
    "closed_without_merge": CLOSED_WITHOUT_MERGE,
    "pr_closed": CLOSED_WITHOUT_MERGE,
    "supersede": SUPERSEDED,
    "superseded": SUPERSEDED,
    "supersession": SUPERSEDED,
    "already_landed": ALREADY_LANDED,
    "reconcile_already_landed": ALREADY_LANDED,
    "controller_closure": CONTROLLER_CLOSURE,
    "close_issue": CONTROLLER_CLOSURE,
    "abandon": ABANDONED,
    "abandoned": ABANDONED,
    "retry": RETRY_RECOVERY,
    "recovery": RETRY_RECOVERY,
    "retry_recovery": RETRY_RECOVERY,
}

#: Human-readable phrasing used in audit comments and diagnostics.
REASON_DESCRIPTIONS: dict[str, str] = {
    MERGED: "the linked PR was merged",
    CLOSED_WITHOUT_MERGE: "the linked PR was closed without merging",
    SUPERSEDED: "the linked PR was superseded by another merged PR",
    ALREADY_LANDED: "the linked PR's change was already on the target branch",
    CONTROLLER_CLOSURE: "the issue reached controller closure",
    ABANDONED: "the linked PR was abandoned",
    RETRY_RECOVERY: "a partial terminal transition is being recovered",
}


def canonical_terminal_reason(reason: str | None) -> str:
    """Normalize a terminal reason, failing closed on anything unrecognized."""
    name = (reason or "").strip()
    if name in TERMINAL_REASONS:
        return name
    normalized = name.lower().replace("-", "_").replace(" ", "_")
    try:
        return _REASON_ALIASES[normalized]
    except KeyError as exc:
        raise ValueError(
            f"unknown terminal PR reason '{reason}' (expected one of: "
            + ", ".join(TERMINAL_REASONS)
            + ")"
        ) from exc


def plan_pr_open_cleanup(
    current_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
    *,
    terminal_reason: str,
) -> dict[str, Any]:
    """Plan the label set an issue must carry after a terminal PR transition.

    The plan removes ``status:pr-open`` and nothing else. When the label is
    absent the plan is an explicit no-op (``cleanup_required`` False), which is
    what makes repeated cleanup calls harmless. When it was the only label the
    resulting set is legitimately empty.
    """
    reason = canonical_terminal_reason(terminal_reason)
    before = issue_workflow_labels.label_names(current_labels)
    after = [name for name in before if name != PR_OPEN_LABEL]
    present = len(after) != len(before)
    return {
        "terminal_reason": reason,
        "terminal_reason_description": REASON_DESCRIPTIONS[reason],
        "label": PR_OPEN_LABEL,
        "label_present": present,
        "cleanup_required": present,
        "idempotent_noop": not present,
        "labels_before": before,
        "labels_after": after,
        "removed": [PR_OPEN_LABEL] if present else [],
        "preserved": list(after),
        "empty_label_set": not after,
    }


def verify_pr_open_cleanup(
    observed_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
    *,
    plan: Mapping[str, Any],
) -> dict[str, Any]:
    """Read-after-write check for a planned cleanup.

    Verifies the label is gone and that the observed set matches the plan
    exactly, so an unrelated label silently dropped (or re-added) by the API is
    reported rather than accepted.
    """
    observed = issue_workflow_labels.label_names(observed_labels)
    expected = list(plan.get("labels_after") or [])
    observed_set = set(observed)
    expected_set = set(expected)
    residual = PR_OPEN_LABEL in observed_set
    unexpected_removals = sorted(expected_set - observed_set)
    unexpected_additions = sorted(observed_set - expected_set - {PR_OPEN_LABEL})

    reasons: list[str] = []
    if residual:
        reasons.append(
            f"'{PR_OPEN_LABEL}' is still present after terminal cleanup"
        )
    if unexpected_removals:
        reasons.append(
            "unrelated labels were dropped by the cleanup: "
            + ", ".join(unexpected_removals)
        )
    if unexpected_additions:
        reasons.append(
            "unexpected labels appeared during the cleanup: "
            + ", ".join(unexpected_additions)
        )

    verified = not reasons
    return {
        "verified": verified,
        "residual": residual,
        "observed_labels": observed,
        "expected_labels": expected,
        "unexpected_removals": unexpected_removals,
        "unexpected_additions": unexpected_additions,
        "empty_label_set": not observed,
        "reasons": reasons,
        "safe_next_action": (
            ""
            if verified
            else (
                "Re-run the terminal cleanup for this issue with "
                f"terminal_reason='{RETRY_RECOVERY}' and confirm the read-back "
                f"no longer reports '{PR_OPEN_LABEL}'."
            )
        ),
    }


def summarize_cleanup_results(
    results: Sequence[Mapping[str, Any]],
    *,
    terminal_reason: str,
) -> dict[str, Any]:
    """Aggregate per-issue cleanup outcomes into one reportable record."""
    reason = canonical_terminal_reason(terminal_reason)
    entries = [dict(entry) for entry in results]
    removed = [e.get("issue_number") for e in entries if e.get("status") == "removed"]
    absent = [
        e.get("issue_number") for e in entries if e.get("status") == "not present"
    ]
    failed = [
        e.get("issue_number")
        for e in entries
        if e.get("status") not in ("removed", "not present") or not e.get("verified")
    ]
    reasons: list[str] = []
    for entry in entries:
        for text in entry.get("reasons") or []:
            reasons.append(f"issue #{entry.get('issue_number')}: {text}")
    clean = not failed
    return {
        "label": PR_OPEN_LABEL,
        "terminal_reason": reason,
        "clean": clean,
        "checked": [e.get("issue_number") for e in entries],
        "removed": removed,
        "already_absent": absent,
        "failed": failed,
        "results": entries,
        "reasons": reasons,
        "safe_next_action": (
            ""
            if clean
            else (
                "Terminal label cleanup did not complete for "
                + ", ".join(f"#{num}" for num in failed)
                + ". Re-run gitea_cleanup_terminal_pr_labels with "
                f"terminal_reason='{RETRY_RECOVERY}' for those issues."
            )
        ),
    }


def detect_residual_pr_open(
    issues: Iterable[Mapping[str, Any]],
    *,
    open_pr_issue_numbers: Iterable[int] = (),
) -> dict[str, Any]:
    """Terminal validation: report issues still carrying ``status:pr-open``.

    An issue with a genuinely open pull request is allowed to keep the label,
    so *open_pr_issue_numbers* is excluded from the residual set rather than
    being reported as a leak.
    """
    legitimate: set[int] = set()
    for num in open_pr_issue_numbers or ():
        try:
            legitimate.add(int(num))
        except (TypeError, ValueError):
            continue

    checked = 0
    residual: list[dict[str, Any]] = []
    exempt: list[int] = []

    for issue in issues or []:
        checked += 1
        names = issue_workflow_labels.label_names(issue)
        if PR_OPEN_LABEL not in names:
            continue
        try:
            number = int(issue.get("number"))
        except (TypeError, ValueError):
            number = None
        if number is not None and number in legitimate:
            exempt.append(number)
            continue
        residual.append(
            {
                "number": number,
                "state": issue.get("state"),
                "labels": names,
            }
        )

    clean = not residual
    reasons = [
        (
            f"issue #{entry['number']} ({entry.get('state') or 'unknown state'}) "
            f"still carries '{PR_OPEN_LABEL}' with no open PR"
        )
        for entry in residual
    ]
    return {
        "label": PR_OPEN_LABEL,
        "clean": clean,
        "checked_count": checked,
        "residual_count": len(residual),
        "residual_issues": residual,
        "exempt_open_pr_issues": sorted(exempt),
        "reasons": reasons,
        "safe_next_action": (
            ""
            if clean
            else (
                "Run gitea_cleanup_terminal_pr_labels with "
                f"terminal_reason='{RETRY_RECOVERY}' for issues "
                + ", ".join(f"#{entry['number']}" for entry in residual)
                + " before declaring the terminal transition complete."
            )
        ),
    }
