"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693).

#332 correctly hard-stops a reviewer session after a terminal live review
mutation. After #559 those locks are durable on disk, so they can outlive the
work they protect: when the referenced PR is already merged or closed, no
same-PR merge sequence remains, yet the durable ledger still blocks unrelated
new terminal reviews.

#620 scopes the terminal boundary by **reviewed head SHA**: a prior
REQUEST_CHANGES (or APPROVE) on head A must not block a fresh formal decision
on head B of the **same open PR**. Same-head duplicates remain fail-closed.
Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed
(#594); new-head re-review is allowed without deleting the durable lock.

#693 scopes the terminal boundary by **PR number**: a terminal on open PR A
must not block a fresh formal decision on open PR B on the same durable
profile lock (cross-PR isolation). Correction authorization is scoped to the
named prior review's PR/head and cannot unlock a different PR.

This module is pure policy (no Gitea I/O). The MCP tool
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
helpers, and only then clears durable state when cleanup is allowed.

Manual deletion of ``~/.cache/gitea-tools/session-state/*`` is never the
canonical path.
"""

from __future__ import annotations

from datetime import datetime, timezone
from typing import Any

# Keep in sync with gitea_mcp_server._TERMINAL_REVIEW_ACTIONS.
TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})


def normalize_head_sha(value: Any) -> str | None:
    """Normalize a git SHA for equality comparison; None if empty/invalid."""
    if value is None:
        return None
    text = str(value).strip().lower()
    if not text:
        return None
    return text


def heads_equal(a: Any, b: Any) -> bool:
    na = normalize_head_sha(a)
    nb = normalize_head_sha(b)
    if not na or not nb:
        return False
    return na == nb


def mutation_head_sha(mutation: dict | None, lock: dict | None = None) -> str | None:
    """Head SHA that bounds a live mutation (#620).

    Prefers fields recorded on the mutation itself. For *legacy* mutations
    that predate head-scoping, falls back to the lock's
    ``ready_expected_head_sha`` only when that ready binding is for the same
    PR number (the frozen durable PR #619-style ledger).
    """
    if not isinstance(mutation, dict):
        return None
    for key in ("head_sha", "expected_head_sha", "reviewed_head_sha"):
        head = normalize_head_sha(mutation.get(key))
        if head:
            return head
    if not isinstance(lock, dict):
        return None
    if lock.get("ready_pr_number") != mutation.get("pr_number"):
        return None
    return normalize_head_sha(lock.get("ready_expected_head_sha"))


def last_terminal_mutation(lock: dict | None) -> dict | None:
    """Return the last terminal live mutation on *lock*, or None."""
    if not lock:
        return None
    terminals = [
        m
        for m in (lock.get("live_mutations") or [])
        if isinstance(m, dict) and m.get("action") in TERMINAL_REVIEW_ACTIONS
    ]
    return terminals[-1] if terminals else None


def correction_applies(
    lock: dict | None,
    *,
    pr_number: int | None = None,
    expected_head_sha: str | None = None,
) -> bool:
    """True when operator correction is active and scoped to the target (#693).

    Global ``correction_authorized`` alone is not enough: correction must name
    the same PR (and head when recorded) as the decision being re-opened.
    Missing scope fields on legacy locks fail closed (do not apply).
    """
    if not lock or not lock.get("correction_authorized"):
        return False
    corr_pr = lock.get("correction_pr_number")
    if corr_pr is None:
        # Legacy unscoped correction — refuse as generic unlock (#693).
        return False
    if pr_number is not None and int(corr_pr) != int(pr_number):
        return False
    corr_head = normalize_head_sha(lock.get("correction_head_sha"))
    target = normalize_head_sha(expected_head_sha)
    if corr_head and target and not heads_equal(corr_head, target):
        return False
    return True


def prior_live_mutations_block_boundary(
    lock: dict | None,
    *,
    pr_number: int,
    expected_head_sha: str | None,
) -> bool:
    """True when prior live mutations block a new decision for PR+head (#620/#693).

    Historical terminals on a **different head of the same PR** do not block.
    Terminals on a **different PR** do not block (#693 cross-PR isolation).
    Same PR + same head (or same PR without a comparable head SHA) blocks
    unless a scoped correction applies.

    Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
    that only stored ``ready_expected_head_sha`` still compare correctly
    (PR #619-style).
    """
    if not lock:
        return False
    if correction_applies(
        lock, pr_number=pr_number, expected_head_sha=expected_head_sha
    ):
        return False
    prior = list(lock.get("live_mutations") or [])
    if not prior:
        return False
    target = normalize_head_sha(expected_head_sha)
    for m in prior:
        if not isinstance(m, dict):
            return True
        m_pr = m.get("pr_number")
        if m_pr is None:
            return True  # fail closed — unscoped mutation
        if int(m_pr) != int(pr_number):
            # #693: foreign-PR history on the shared durable lock does not block.
            continue
        m_head = mutation_head_sha(m, lock)
        if (
            target
            and m_head
            and not heads_equal(m_head, target)
        ):
            # Same open PR, different reviewed head — historical boundary only.
            continue
        return True
    return False


def terminal_boundary_allows_fresh_decision(
    lock: dict | None,
    *,
    pr_number: int,
    expected_head_sha: str | None,
    operation: str,
) -> bool:
    """Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693).

    For ``mark_ready`` / ``review`` / ``resume``:

    * **same PR, different head** — allowed (#620)
    * **different PR than last terminal** — allowed (#693 cross-PR isolation)
    * **same PR, same head** — not allowed (unless scoped correction)

    Merge is never reopened by head change or cross-PR isolation (approved
    head merge path is separate).
    """
    if operation not in ("mark_ready", "review", "resume"):
        return False
    if not lock:
        return False
    if correction_applies(
        lock, pr_number=pr_number, expected_head_sha=expected_head_sha
    ):
        return True
    last = last_terminal_mutation(lock)
    if last is None:
        return True
    last_pr = last.get("pr_number")
    if last_pr is None:
        return False
    if int(last_pr) != int(pr_number):
        # #693: last terminal belongs to another PR — do not hard-stop this PR.
        return True
    locked_head = mutation_head_sha(last, lock)
    target = normalize_head_sha(expected_head_sha)
    if not locked_head or not target:
        return False
    return not heads_equal(locked_head, target)


def backfill_terminal_heads_from_ready(lock: dict) -> dict:
    """Stamp head_sha onto legacy terminal mutations before overwriting ready_*.

    Called when marking a fresh decision on a new head so historical rows keep
    their original boundary after ``ready_expected_head_sha`` advances (#620).
    """
    if not isinstance(lock, dict):
        return lock
    ready_pr = lock.get("ready_pr_number")
    ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
    if ready_pr is None or not ready_head:
        return lock
    mutations = list(lock.get("live_mutations") or [])
    changed = False
    for m in mutations:
        if not isinstance(m, dict):
            continue
        if m.get("action") not in TERMINAL_REVIEW_ACTIONS:
            continue
        if m.get("pr_number") != ready_pr:
            continue
        if mutation_head_sha(m):
            continue
        m["head_sha"] = ready_head
        changed = True
    if changed:
        lock["live_mutations"] = mutations
    return lock


def classify_pr_live_state(pr_live: dict | None) -> dict[str, Any]:
    """Normalize a Gitea PR payload into merge/closed flags."""
    pr = pr_live or {}
    merged = bool(pr.get("merged") or pr.get("merged_at"))
    state = (pr.get("state") or "").strip().lower() or None
    closed = state == "closed"
    head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
    head_sha = normalize_head_sha(
        pr.get("head_sha") or pr.get("head_commit_sha") or head.get("sha")
    )
    return {
        "pr_state": state,
        "pr_merged": merged,
        "pr_merged_or_closed": merged or closed,
        "merge_commit_sha": pr.get("merge_commit_sha")
        or pr.get("merged_commit_sha"),
        "current_pr_head_sha": head_sha,
    }


def lock_summary(lock: dict | None) -> dict[str, Any] | None:
    """LLM-safe summary of a decision lock (no secrets)."""
    if lock is None:
        return None
    last = last_terminal_mutation(lock)
    mutations = list(lock.get("live_mutations") or [])
    last_head = mutation_head_sha(last, lock) if last else None
    return {
        "task": lock.get("task"),
        "remote": lock.get("remote"),
        "org": lock.get("org") or lock.get("ready_org"),
        "repo": lock.get("repo") or lock.get("ready_repo"),
        "profile_identity": lock.get("profile_identity")
        or lock.get("session_profile_lock")
        or lock.get("session_profile"),
        "session_profile": lock.get("session_profile"),
        "ready_pr_number": lock.get("ready_pr_number"),
        "ready_action": lock.get("ready_action"),
        "ready_expected_head_sha": normalize_head_sha(
            lock.get("ready_expected_head_sha")
        ),
        "live_mutations_count": len(mutations),
        "last_terminal": (
            {
                "pr_number": last.get("pr_number"),
                "action": last.get("action"),
                "review_id": last.get("review_id"),
                "head_sha": last_head,
            }
            if last
            else None
        ),
        "locked_head_sha": last_head,
        "correction_authorized": bool(lock.get("correction_authorized")),
        "updated_at": lock.get("updated_at") or lock.get("recorded_at"),
    }


def assess_stale_review_decision_lock(
    lock: dict | None,
    *,
    pr_live: dict | None = None,
    pr_lookup_error: str | None = None,
    active_profile_identity: str | None = None,
    current_pr_head_sha: str | None = None,
) -> dict[str, Any]:
    """Assess whether a durable review-decision lock is stale/moot (#594/#620).

    *pr_live* must be the live Gitea payload for the **last terminal
    mutation's PR**. When no lock / no terminal mutation exists, cleanup is
    not applicable. When the PR is still open (or lookup fails), cleanup is
    forbidden (#594). Open PR + different live head reports
    ``stale_by_head`` / ``fresh_review_on_current_head_allowed`` (#620)
    without enabling cleanup.
    """
    summary = lock_summary(lock)
    result: dict[str, Any] = {
        "has_lock": lock is not None,
        "lock_summary": summary,
        "last_terminal_pr": None,
        "last_terminal_action": None,
        "locked_head_sha": None,
        "current_pr_head_sha": normalize_head_sha(current_pr_head_sha),
        "stale_by_head": False,
        "fresh_review_on_current_head_allowed": False,
        "is_moot": False,
        "cleanup_allowed": False,
        "profile_match": True,
        "pr_state": None,
        "pr_merged": False,
        "pr_merged_or_closed": False,
        "merge_commit_sha": None,
        "reasons": [],
        "recovery_tool": "gitea_cleanup_stale_review_decision_lock",
    }

    if lock is None:
        result["reasons"].append("no review decision lock present")
        return result

    # Profile identity gate (caller may re-check with env; pure assess still
    # reports mismatch when active identity is supplied).
    stored = (
        (lock.get("profile_identity") or lock.get("session_profile_lock") or "")
        .strip()
    )
    active = (active_profile_identity or "").strip()
    if active and stored and active != stored:
        result["profile_match"] = False
        result["reasons"].append(
            "review decision lock profile identity does not match active "
            f"profile '{active}' (fail closed, #594)"
        )
        return result

    last = last_terminal_mutation(lock)
    if last is None:
        result["reasons"].append(
            "review decision lock has no terminal live mutations; nothing to clear"
        )
        return result

    pr_number = last.get("pr_number")
    action = last.get("action")
    locked_head = mutation_head_sha(last, lock)
    result["last_terminal_pr"] = pr_number
    result["last_terminal_action"] = action
    result["locked_head_sha"] = locked_head

    if pr_number is None:
        result["reasons"].append(
            "last terminal mutation missing pr_number; refuse cleanup (fail closed)"
        )
        return result

    if pr_lookup_error:
        result["reasons"].append(
            f"could not load live state for PR #{pr_number}: {pr_lookup_error} "
            "(fail closed, #594)"
        )
        return result

    if pr_live is None:
        result["reasons"].append(
            f"live PR state required for terminal PR #{pr_number} before cleanup "
            "(fail closed, #594)"
        )
        return result

    live = classify_pr_live_state(pr_live)
    current_head = normalize_head_sha(
        current_pr_head_sha or live.get("current_pr_head_sha")
    )
    result.update(
        {
            "pr_state": live["pr_state"],
            "pr_merged": live["pr_merged"],
            "pr_merged_or_closed": live["pr_merged_or_closed"],
            "merge_commit_sha": live["merge_commit_sha"],
            "current_pr_head_sha": current_head,
        }
    )

    if not live["pr_merged_or_closed"]:
        stale_by_head = bool(
            locked_head and current_head and not heads_equal(locked_head, current_head)
        )
        result["stale_by_head"] = stale_by_head
        # Fresh formal decision is allowed on the new head without cleanup (#620).
        result["fresh_review_on_current_head_allowed"] = stale_by_head
        if stale_by_head:
            result["reasons"].append(
                f"terminal {action} on PR #{pr_number} is bound to head "
                f"{locked_head[:12]}…; live head is {current_head[:12]}… — "
                "fresh formal review on current head is allowed without "
                "cleanup (fail closed for cleanup, #620); #332 same-head "
                "duplicate protection remains"
            )
        else:
            result["reasons"].append(
                f"terminal review mutation on open PR #{pr_number} is still active "
                f"({action}); #332 hard-stop remains — cleanup forbidden (#594)"
            )
        return result

    # Merged or closed: lock is moot. Same-PR merge continuation is impossible.
    result["is_moot"] = True
    result["cleanup_allowed"] = True
    result["stale_by_head"] = False
    result["fresh_review_on_current_head_allowed"] = False
    result["reasons"].append(
        f"terminal mutation ({action} on PR #{pr_number}) is moot: PR is "
        f"{'merged' if live['pr_merged'] else 'closed'}; canonical cleanup allowed (#594)"
    )
    return result


def build_cleanup_audit_record(
    *,
    assessment: dict[str, Any],
    actor_username: str | None,
    profile_name: str | None,
    applied: bool,
) -> dict[str, Any]:
    """Structured durable audit payload for a cleanup attempt."""
    last = (assessment.get("lock_summary") or {}).get("last_terminal") or {}
    return {
        "event": "stale_review_decision_lock_cleanup",
        "issue_ref": "#594",
        "applied": bool(applied),
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "actor_username": actor_username,
        "profile_name": profile_name,
        "last_terminal_pr": assessment.get("last_terminal_pr") or last.get("pr_number"),
        "last_terminal_action": assessment.get("last_terminal_action")
        or last.get("action"),
        "pr_state": assessment.get("pr_state"),
        "pr_merged": assessment.get("pr_merged"),
        "pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
        "merge_commit_sha": assessment.get("merge_commit_sha"),
        "prior_live_mutations_count": (
            (assessment.get("lock_summary") or {}).get("live_mutations_count")
        ),
        "prior_profile_identity": (
            (assessment.get("lock_summary") or {}).get("profile_identity")
        ),
        "cleanup_allowed": assessment.get("cleanup_allowed"),
        "is_moot": assessment.get("is_moot"),
        "reasons": list(assessment.get("reasons") or []),
    }


def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
    """Markdown body for a durable Gitea audit comment after cleanup."""
    status = "APPLIED" if audit.get("applied") else "ASSESSED (not applied)"
    lines = [
        "## Stale #332 review-decision lock cleanup (#594)",
        "",
        f"Status: **{status}**",
        "",
        f"- actor: `{audit.get('actor_username')}`",
        f"- profile: `{audit.get('profile_name')}`",
        f"- timestamp: `{audit.get('timestamp')}`",
        f"- last terminal: `{audit.get('last_terminal_action')}` on "
        f"PR #{audit.get('last_terminal_pr')}",
        f"- PR state: `{audit.get('pr_state')}` "
        f"(merged={audit.get('pr_merged')})",
        f"- merge_commit_sha: `{audit.get('merge_commit_sha')}`",
        f"- prior live_mutations_count: "
        f"`{audit.get('prior_live_mutations_count')}`",
        f"- prior profile_identity: `{audit.get('prior_profile_identity')}`",
        "",
        "Manual deletion of session-state files is **not** the workflow.",
        "This path only clears a lock when the referenced PR is merged/closed.",
    ]
    return "\n".join(lines)


# --- #693 classification / recovery / correction audit -----------------------

# Deterministic classification labels for diagnostics and recovery routing.
CLASS_NO_LOCK = "no_lock"
CLASS_READY_FOR_TARGET = "ready_for_target"
CLASS_IN_PROGRESS_SAME_HEAD = "in_progress_same_head"
CLASS_TERMINAL_ACTIVE_SAME_HEAD = "terminal_active_same_head"
CLASS_STALE_SUPERSEDED_HEAD = "stale_superseded_head"
CLASS_FOREIGN_PR_TERMINAL = "foreign_pr_terminal"
CLASS_MOOT_MERGED_CLOSED = "moot_merged_closed"
CLASS_CORRECTION_ACTIVE = "correction_active"
CLASS_SESSION_OR_PROFILE_MISMATCH = "session_or_profile_mismatch"
CLASS_AMBIGUOUS = "ambiguous"


def classify_review_decision_lock(
    lock: dict | None,
    *,
    target_pr_number: int | None = None,
    target_head_sha: str | None = None,
    pr_live: dict | None = None,
    pr_lookup_error: str | None = None,
    active_profile_identity: str | None = None,
    session_blockers: list[str] | None = None,
) -> dict[str, Any]:
    """Deterministic review-decision-lock classification (#693).

    Returns classification, exact next_action, owner/evidence fields, and
    whether mark_final / cleanup / correction are appropriate.
    """
    summary = lock_summary(lock)
    target_head = normalize_head_sha(target_head_sha)
    result: dict[str, Any] = {
        "classification": CLASS_NO_LOCK,
        "has_lock": lock is not None,
        "lock_summary": summary,
        "target_pr_number": target_pr_number,
        "target_head_sha": target_head,
        "last_terminal_pr": None,
        "last_terminal_action": None,
        "locked_head_sha": None,
        "owner_profile_identity": (summary or {}).get("profile_identity"),
        "session_profile": (summary or {}).get("session_profile"),
        "updated_at": (summary or {}).get("updated_at"),
        "correction_authorized": bool((lock or {}).get("correction_authorized")),
        "correction_pr_number": (lock or {}).get("correction_pr_number"),
        "correction_head_sha": normalize_head_sha(
            (lock or {}).get("correction_head_sha")
        ),
        "mark_final_allowed": True,
        "cleanup_allowed": False,
        "correction_eligible": False,
        "next_action": "gitea_resolve_task_capability(task='review_pr') then mark_final",
        "reasons": [],
        "evidence": {},
    }

    if lock is None:
        result["reasons"].append("no review decision lock present")
        return result

    session_blockers = list(session_blockers or [])
    if session_blockers:
        result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
        result["mark_final_allowed"] = False
        result["next_action"] = (
            "reconnect matching prgs-reviewer session or wait for lock TTL; "
            "do not use gitea_authorize_review_correction as unlock; "
            "do not delete session-state files"
        )
        result["reasons"].extend(session_blockers)
        return result

    stored = (
        (lock.get("profile_identity") or lock.get("session_profile_lock") or "")
        .strip()
    )
    active = (active_profile_identity or "").strip()
    if active and stored and active != stored:
        result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
        result["mark_final_allowed"] = False
        result["next_action"] = (
            f"switch to profile '{stored}' or wait for moot cleanup; "
            "do not use correction authorization as a generic unlock"
        )
        result["reasons"].append(
            f"lock profile '{stored}' does not match active '{active}'"
        )
        return result

    last = last_terminal_mutation(lock)
    if last is None:
        ready_pr = lock.get("ready_pr_number")
        ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
        if (
            target_pr_number is not None
            and ready_pr == target_pr_number
            and ready_head
            and target_head
            and heads_equal(ready_head, target_head)
            and lock.get("final_review_decision_ready")
        ):
            result["classification"] = CLASS_IN_PROGRESS_SAME_HEAD
            result["mark_final_allowed"] = True  # idempotent re-mark
            result["next_action"] = (
                "gitea_submit_pr_review with final_review_decision_ready=true "
                "for the ready PR/head"
            )
            result["reasons"].append(
                "final decision already marked ready for this PR/head (idempotent)"
            )
            return result
        result["classification"] = CLASS_READY_FOR_TARGET
        result["next_action"] = "gitea_mark_final_review_decision then submit"
        result["reasons"].append("no terminal live mutations; mark_final allowed")
        return result

    last_pr = last.get("pr_number")
    last_action = last.get("action")
    locked_head = mutation_head_sha(last, lock)
    result["last_terminal_pr"] = last_pr
    result["last_terminal_action"] = last_action
    result["locked_head_sha"] = locked_head
    result["evidence"] = {
        "last_review_id": last.get("review_id"),
        "live_mutations_count": len(lock.get("live_mutations") or []),
    }

    if correction_applies(
        lock, pr_number=target_pr_number, expected_head_sha=target_head
    ):
        result["classification"] = CLASS_CORRECTION_ACTIVE
        result["mark_final_allowed"] = True
        result["next_action"] = (
            "gitea_mark_final_review_decision for the correction-scoped PR/head, "
            "then submit once"
        )
        result["reasons"].append(
            f"scoped correction active for PR #{lock.get('correction_pr_number')}"
        )
        return result

    # Live state of last terminal PR when provided.
    live = None
    if pr_lookup_error:
        result["classification"] = CLASS_AMBIGUOUS
        result["mark_final_allowed"] = False
        result["next_action"] = (
            "retry diagnosis after live PR state is available; "
            "fail closed under ambiguous evidence"
        )
        result["reasons"].append(f"live PR lookup failed: {pr_lookup_error}")
        return result
    if pr_live is not None:
        live = classify_pr_live_state(pr_live)
        result["evidence"]["last_terminal_pr_state"] = live.get("pr_state")
        result["evidence"]["last_terminal_pr_merged"] = live.get("pr_merged")
        if live.get("pr_merged_or_closed"):
            result["classification"] = CLASS_MOOT_MERGED_CLOSED
            result["cleanup_allowed"] = True
            result["mark_final_allowed"] = target_pr_number is not None and (
                int(last_pr) != int(target_pr_number)
                if last_pr is not None and target_pr_number is not None
                else True
            )
            result["next_action"] = (
                "gitea_cleanup_stale_review_decision_lock(apply=true, "
                f"expected_terminal_pr={last_pr}) then mark_final on the target PR"
            )
            result["reasons"].append(
                f"terminal on PR #{last_pr} is moot (merged/closed); cleanup allowed"
            )
            return result

    if target_pr_number is None:
        result["classification"] = CLASS_AMBIGUOUS
        result["mark_final_allowed"] = False
        result["next_action"] = "re-run diagnosis with target_pr_number"
        result["reasons"].append("target_pr_number required for open-PR classification")
        return result

    if last_pr is not None and int(last_pr) != int(target_pr_number):
        # Cross-PR isolation: foreign terminal is history, not a block.
        result["classification"] = CLASS_FOREIGN_PR_TERMINAL
        result["mark_final_allowed"] = True
        result["correction_eligible"] = False  # must not use correction to "unlock"
        result["next_action"] = (
            f"gitea_mark_final_review_decision(pr_number={target_pr_number}, ...) "
            f"— foreign terminal on PR #{last_pr} does not block (#693); "
            "do not call gitea_authorize_review_correction for cross-PR unlock"
        )
        result["reasons"].append(
            f"last terminal is {last_action} on foreign open/closed PR #{last_pr}; "
            f"target PR #{target_pr_number} is isolated (#693)"
        )
        return result

    # Same PR as target.
    if (
        locked_head
        and target_head
        and not heads_equal(locked_head, target_head)
    ):
        result["classification"] = CLASS_STALE_SUPERSEDED_HEAD
        result["mark_final_allowed"] = True
        result["next_action"] = (
            f"gitea_mark_final_review_decision with expected_head_sha="
            f"{target_head} (#620 same-PR new head)"
        )
        result["reasons"].append(
            f"terminal {last_action} on head {locked_head[:12]}…; target head "
            f"{target_head[:12]}… — fresh formal review allowed without cleanup"
        )
        return result

    # Same PR + same head (or missing head comparison) — active terminal.
    result["classification"] = CLASS_TERMINAL_ACTIVE_SAME_HEAD
    result["mark_final_allowed"] = False
    result["correction_eligible"] = True
    result["next_action"] = (
        "if the prior verdict was mistaken: gitea_authorize_review_correction "
        f"with prior_review_id={last.get('review_id')}, "
        f"prior_review_state={last_action}, target_pr_number={target_pr_number}, "
        "operator_authorized=true, then re-mark once; "
        "if the prior verdict is correct: stop and hand off (do not duplicate)"
    )
    result["reasons"].append(
        f"terminal {last_action} already recorded for PR #{target_pr_number} "
        f"at this head; same-head duplicate blocked (#332/#620)"
    )
    return result


def build_precise_mark_final_failure(
    classification: dict[str, Any],
) -> dict[str, Any]:
    """One precise recovery instruction + durable issue handoff payload (#693 AC4/8)."""
    cls = classification.get("classification")
    next_action = classification.get("next_action") or (
        "stop and create a durable Gitea issue with lock evidence"
    )
    reasons = list(classification.get("reasons") or [])
    reasons.append(f"exact_next_action: {next_action}")
    handoff = {
        "required": cls
        in (
            CLASS_AMBIGUOUS,
            CLASS_SESSION_OR_PROFILE_MISMATCH,
            CLASS_TERMINAL_ACTIVE_SAME_HEAD,
        )
        and not classification.get("mark_final_allowed"),
        "title_hint": (
            "Review-decision-lock recovery failed during formal review"
        ),
        "classification": cls,
        "exact_next_action": next_action,
        "owner_profile_identity": classification.get("owner_profile_identity"),
        "last_terminal_pr": classification.get("last_terminal_pr"),
        "last_terminal_action": classification.get("last_terminal_action"),
        "locked_head_sha": classification.get("locked_head_sha"),
        "target_pr_number": classification.get("target_pr_number"),
        "target_head_sha": classification.get("target_head_sha"),
        "evidence": classification.get("evidence") or {},
        "instruction": (
            "If recovery cannot complete with the exact_next_action above, "
            "create or update a durable Gitea issue with this payload and stop; "
            "do not delete session-state files; do not misuse correction as unlock."
        ),
    }
    return {
        "reasons": reasons,
        "classification": cls,
        "exact_next_action": next_action,
        "durable_issue_handoff": handoff,
    }


def build_correction_audit_record(
    *,
    prior_review_id: int | None,
    prior_review_state: str | None,
    target_pr_number: int | None,
    target_head_sha: str | None,
    reason: str | None,
    actor_username: str | None,
    profile_name: str | None,
    authorized: bool,
) -> dict[str, Any]:
    """Structured durable audit for review-correction authorization (#693)."""
    return {
        "event": "review_correction_authorization",
        "issue_ref": "#693",
        "authorized": bool(authorized),
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "actor_username": actor_username,
        "profile_name": profile_name,
        "prior_review_id": prior_review_id,
        "prior_review_state": prior_review_state,
        "target_pr_number": target_pr_number,
        "target_head_sha": normalize_head_sha(target_head_sha),
        "reason": reason,
        "scope": "same_pr_head_only",
    }


def format_correction_audit_comment(audit: dict[str, Any]) -> str:
    """Markdown body for a thread-visible correction authorization audit."""
    status = "AUTHORIZED" if audit.get("authorized") else "DENIED"
    lines = [
        "## Review correction authorization audit (#693)",
        "",
        f"Status: **{status}**",
        "",
        f"- actor: `{audit.get('actor_username')}`",
        f"- profile: `{audit.get('profile_name')}`",
        f"- timestamp: `{audit.get('timestamp')}`",
        f"- prior_review_id: `{audit.get('prior_review_id')}`",
        f"- prior_review_state: `{audit.get('prior_review_state')}`",
        f"- target_pr: `#{audit.get('target_pr_number')}`",
        f"- target_head_sha: `{audit.get('target_head_sha')}`",
        f"- reason: {audit.get('reason')}",
        f"- scope: `{audit.get('scope')}` (cannot unlock a different PR)",
        "",
        "This is **not** a generic decision-lock unlock. Cross-PR recovery "
        "uses isolation (#693) or moot cleanup (#594), never correction.",
    ]
    return "\n".join(lines)


def assess_open_pr_decision_lock_recovery(
    lock: dict | None,
    *,
    target_pr_number: int,
    target_head_sha: str | None,
    last_terminal_pr_live: dict | None = None,
    pr_lookup_error: str | None = None,
    active_profile_identity: str | None = None,
    session_blockers: list[str] | None = None,
    controller_recovery_authorized: bool = False,
) -> dict[str, Any]:
    """Assess guarded recovery for decision locks affecting an open target PR (#693).

    Recovery here means *workflow recovery routing*, not necessarily lock wipe.
    Foreign-PR terminals are recoverable by isolation (mark_final allowed).
    Moot terminals use #594 cleanup. Same-head active terminals refuse wipe.
    """
    classification = classify_review_decision_lock(
        lock,
        target_pr_number=target_pr_number,
        target_head_sha=target_head_sha,
        pr_live=last_terminal_pr_live,
        pr_lookup_error=pr_lookup_error,
        active_profile_identity=active_profile_identity,
        session_blockers=session_blockers,
    )
    cls = classification["classification"]
    recovery_allowed = False
    recovery_mode = None
    if cls == CLASS_FOREIGN_PR_TERMINAL:
        recovery_allowed = True
        recovery_mode = "cross_pr_isolation"
    elif cls == CLASS_STALE_SUPERSEDED_HEAD:
        recovery_allowed = True
        recovery_mode = "same_pr_new_head"
    elif cls == CLASS_MOOT_MERGED_CLOSED:
        recovery_allowed = True
        recovery_mode = "moot_cleanup"
    elif cls == CLASS_READY_FOR_TARGET:
        recovery_allowed = True
        recovery_mode = "none_required"
    elif cls == CLASS_CORRECTION_ACTIVE:
        recovery_allowed = True
        recovery_mode = "scoped_correction"
    elif cls == CLASS_TERMINAL_ACTIVE_SAME_HEAD:
        recovery_allowed = False
        recovery_mode = "correction_only_if_mistake"
    else:
        recovery_allowed = False
        recovery_mode = "fail_closed"

    if recovery_mode == "moot_cleanup" and not controller_recovery_authorized:
        # Cleanup still needs apply + capability; assess reports path.
        pass

    return {
        **classification,
        "recovery_allowed": recovery_allowed,
        "recovery_mode": recovery_mode,
        "controller_recovery_authorized": bool(controller_recovery_authorized),
        "manual_file_delete_forbidden": True,
        "correction_as_generic_unlock_forbidden": True,
    }


# ---------------------------------------------------------------------------
# #709 cross-profile cleanup, overwrite protection, recovery provenance
# ---------------------------------------------------------------------------


def has_unresolved_terminal_evidence(lock: dict | None) -> bool:
    """True when *lock* still carries a terminal live mutation ledger."""
    return last_terminal_mutation(lock) is not None


def assess_init_overwrite(
    existing_lock: dict | None,
    *,
    force: bool = False,
) -> dict[str, Any]:
    """Whether init_review_decision_lock may replace an existing durable lock (#709 AC2).

    Unresolved terminal evidence must never be silently replaced by an empty
    initialized lock. *force* does not authorize destruction of terminal
    ledgers — only explicit cleanup / archive transitions may remove them.
    """
    result: dict[str, Any] = {
        "overwrite_allowed": True,
        "has_existing": existing_lock is not None,
        "has_unresolved_terminal": False,
        "reasons": [],
        "last_terminal_pr": None,
        "last_terminal_action": None,
        "existing_profile_identity": None,
    }
    if existing_lock is None:
        result["reasons"].append("no existing lock; empty init allowed")
        return result
    result["existing_profile_identity"] = (
        existing_lock.get("profile_identity")
        or existing_lock.get("session_profile_lock")
        or existing_lock.get("session_profile")
    )
    last = last_terminal_mutation(existing_lock)
    if last is None:
        result["reasons"].append(
            "existing lock has no terminal mutations; re-init allowed"
        )
        return result
    result["has_unresolved_terminal"] = True
    result["overwrite_allowed"] = False
    result["last_terminal_pr"] = last.get("pr_number")
    result["last_terminal_action"] = last.get("action")
    result["reasons"].append(
        "refuse empty re-init: unresolved terminal decision-lock evidence "
        f"present ({last.get('action')} on PR #{last.get('pr_number')}); "
        "use gitea_cleanup_stale_review_decision_lock when moot, or archive "
        f"via sanctioned recovery — force={force!r} does not authorize overwrite "
        "(#709 AC2)"
    )
    return result


def lock_targets_merged_pr_approval(
    lock: dict | None,
    *,
    pr_number: int,
    expected_head_sha: str | None = None,
) -> bool:
    """True when *lock*'s last terminal mutation is approve of *pr_number*.

    When *expected_head_sha* is provided, a **recorded** terminal head must
    match. Legacy same-repo approve locks with no recorded head do **not**
    match (fail closed, #709 F3 residual / review 435) — callers must use the
    strict secondary path that refuses no-head clears.
    """
    last = last_terminal_mutation(lock)
    if last is None:
        return False
    if last.get("action") != "approve":
        return False
    if last.get("pr_number") != pr_number:
        return False
    if expected_head_sha:
        want = normalize_head_sha(expected_head_sha)
        if not want:
            return False
        locked = mutation_head_sha(last, lock)
        # Require recorded-head match; unrecorded head is not a match (#709 F3).
        if not locked or not heads_equal(locked, want):
            return False
    return True


def build_post_merge_recovery_record(
    *,
    pr_number: int,
    head_sha: str | None,
    merge_commit_sha: str | None,
    target_profile_identity: str | None,
    failed_step: str,
    error: str | None,
    remote: str | None,
    org: str | None,
    repo: str | None,
    actor_username: str | None,
    profile_name: str | None,
) -> dict[str, Any]:
    """Durable recovery-required payload after irreversible merge (#709 AC3)."""
    return {
        "event": "post_merge_decision_lock_recovery_required",
        "status": "recovery_required",
        "issue_ref": "#709",
        "recovery_critical": True,
        "applied": False,  # never claim historical cleanup succeeded
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "pr_number": pr_number,
        "head_sha": normalize_head_sha(head_sha),
        "merge_commit_sha": merge_commit_sha,
        "target_profile_identity": target_profile_identity,
        "failed_step": failed_step,
        "error": error,
        "remote": remote,
        "org": org,
        "repo": repo,
        "actor_username": actor_username,
        "profile_name": profile_name,
        "required_recovery_action": (
            "retry cross-profile decision-lock cleanup and audit publication "
            "for the merged PR; if terminal evidence is gone, use "
            "gitea_record_irrecoverable_decision_lock_provenance"
        ),
    }


def build_irrecoverable_provenance_record(
    *,
    pr_number: int,
    head_sha: str | None,
    remote: str | None,
    org: str | None,
    repo: str | None,
    actor_username: str | None,
    profile_name: str | None,
    reason: str,
    incident_ref: str | None = None,
    # Deprecated kwargs retained only so stale call sites fail closed:
    operator_authorized: bool | None = None,
    # Required for merger-acceptable records (#709 F1):
    authorization: dict[str, Any] | None = None,
    incident_issue: int | None = None,
    incident_comment_id: int | None = None,
    destroyed_subject: str | None = None,
    historical_provenance_subject: str | None = None,
) -> dict[str, Any]:
    """Truthful absence-of-proof record (#709 AC5). Never sets applied=True.

    Caller-supplied ``operator_authorized`` is **ignored** as authorization
    evidence (review 434 F1). Prefer
    :func:`irrecoverable_provenance.build_irrecoverable_provenance_record`
    with a verified server-side authorization artifact.
    """
    # Explicitly ignore deprecated self-assertable Boolean.
    _ = operator_authorized
    if authorization is not None and incident_issue is not None and incident_comment_id is not None:
        from irrecoverable_provenance import (
            build_irrecoverable_provenance_record as _build,
        )

        return _build(
            pr_number=int(pr_number),
            head_sha=str(head_sha or ""),
            remote=str(remote or ""),
            org=str(org or ""),
            repo=str(repo or ""),
            actor_username=actor_username,
            profile_name=profile_name,
            reason=reason,
            incident_issue=int(incident_issue),
            incident_comment_id=int(incident_comment_id),
            authorization=authorization,
            destroyed_subject=destroyed_subject,
            historical_provenance_subject=historical_provenance_subject
            or destroyed_subject,
        )
    # Fail-closed skeleton when no server authorization is supplied: never
    # sets merger_may_accept True (even if operator_authorized was True).
    return {
        "event": "irrecoverable_decision_lock_provenance",
        "status": "provenance_irrecoverable",
        "record_type": "irrecoverable_decision_provenance",
        "operator_recovery_required": True,
        "issue_ref": "#709",
        "recovery_critical": True,
        "applied": False,
        "historical_cleanup_proven": False,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "pr_number": pr_number,
        "head_sha": normalize_head_sha(head_sha),
        "remote": remote,
        "org": org,
        "repo": repo,
        "actor_username": actor_username,
        "profile_name": profile_name,
        "reason": reason,
        "incident_ref": incident_ref,
        "incident_issue": incident_issue,
        "incident_comment_id": incident_comment_id,
        "authorization_verified": False,
        "merger_may_accept": False,
        "acceptance_rule": (
            "Merger may accept this record only when a server-side "
            "authorization artifact verifies for remote/org/repo/PR/exact "
            "head/incident, the record is durable and read back, and normal "
            "merge gates still pass. Caller Booleans never authorize. This "
            "does not prove historical cleanup."
        ),
    }


def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str:
    """Markdown body for irrecoverable provenance audit (no applied=true claim)."""
    from irrecoverable_provenance import (
        format_irrecoverable_audit_comment as _fmt,
    )

    return _fmt(record)


def format_post_merge_recovery_comment(record: dict[str, Any]) -> str:
    """Markdown body for post-merge recovery-required audit."""
    lines = [
        "## Post-merge decision-lock recovery required (#709)",
        "",
        "Status: **RECOVERY_REQUIRED** (merge is irreversible; cleanup/audit incomplete)",
        "",
        f"- actor: `{record.get('actor_username')}`",
        f"- profile: `{record.get('profile_name')}`",
        f"- timestamp: `{record.get('timestamp')}`",
        f"- PR: `#{record.get('pr_number')}`",
        f"- head_sha: `{record.get('head_sha')}`",
        f"- merge_commit_sha: `{record.get('merge_commit_sha')}`",
        f"- target_profile_identity: `{record.get('target_profile_identity')}`",
        f"- failed_step: `{record.get('failed_step')}`",
        f"- error: `{record.get('error')}`",
        "",
        f"Required action: {record.get('required_recovery_action')}",
        "",
        "applied=false — do not treat this as successful cleanup evidence.",
    ]
    return "\n".join(lines)

