"""Self-propagating canonical handoffs through final controller closure (#626).

#494-#507 defined the canonical ledger, next-action comments, comment
validation, the controller acceptance gate, and the Canonical Thread Handoff
(CTH) shape.  What none of them enforce is the *chain*: that every actor
consumes exactly one canonical handoff, performs exactly one authorized role,
records the result durably in Gitea, and emits the next complete handoff until
the controller records final closure.

This module owns that systemic gap:

* one canonical cross-role handoff schema (:data:`HANDOFF_FIELDS`);
* a fail-closed validator that rejects incomplete handoffs;
* live-state recovery so a receiving actor never trusts an inherited handoff;
* role-limited continuation;
* mandatory durable posting into Gitea;
* the ``merged-awaiting-controller`` boundary and controller accept/reject
  continuation;
* workflow-failure escalation into separate durable issues, with duplicate
  handling;
* terminal closure that must *not* emit an unnecessary next prompt.

Everything here is pure assessment: no network calls, no mutation.
"""

from __future__ import annotations

import re
from typing import Any, Iterable, Mapping, Sequence

MARKER = "<!-- sph:v1 -->"
HANDOFF_HEADING = "Canonical Handoff"

#: Canonical workflow states a handoff may declare.
WORKFLOW_STATES: tuple[str, ...] = (
    "needs-author",
    "needs-review",
    "approved-awaiting-merge",
    "merged-awaiting-controller",
    "blocked",
    "complete",
)

TERMINAL_STATES = frozenset({"complete"})

#: The single role authorized to act on each workflow state.
NEXT_ACTOR_BY_STATE: dict[str, str] = {
    "needs-author": "author",
    "needs-review": "reviewer",
    "approved-awaiting-merge": "merger",
    "merged-awaiting-controller": "controller",
    "blocked": "operator",
    "complete": "none",
}

WORKFLOW_ROLES = frozenset(
    {"author", "reviewer", "merger", "controller", "operator", "reconciler"}
)

#: What each receiving role is authorized to do when it consumes a handoff.
ROLE_ALLOWED_ACTIONS: dict[str, tuple[str, ...]] = {
    "author": ("implement", "commit", "push", "create_pr", "comment"),
    "reviewer": ("review", "approve", "request_changes", "comment"),
    "merger": ("verify_approval_parity", "merge", "comment"),
    "controller": ("accept", "reject", "reopen", "close_issue", "comment"),
    "operator": ("repair_infrastructure", "comment"),
    "reconciler": ("close_superseded_pr", "cleanup_branch", "comment"),
}

ROLE_FORBIDDEN_ACTIONS: dict[str, tuple[str, ...]] = {
    "author": ("approve", "request_changes", "merge", "close_issue"),
    "reviewer": ("merge", "commit", "push", "create_pr"),
    "merger": ("approve", "commit", "push", "create_pr"),
    "controller": ("approve", "merge", "commit", "push"),
    "operator": ("approve", "merge", "close_issue"),
    "reconciler": ("approve", "merge", "commit", "push", "create_pr"),
}

#: Ordered canonical handoff fields.  Every one of them is required; the
#: fields in :data:`NONE_ALLOWED_FIELDS` may legitimately carry ``none``.
HANDOFF_FIELDS: tuple[str, ...] = (
    "REPOSITORY",
    "ISSUE",
    "PR",
    "WORKFLOW_STATE",
    "HEAD_SHA",
    "BASE_BRANCH",
    "BASE_OR_MERGE_SHA",
    "ACTING_ROLE",
    "ACTING_IDENTITY",
    "COMPLETED_ACTIONS",
    "VALIDATION_EVIDENCE",
    "MUTATION_LEDGER",
    "BLOCKERS",
    "NEXT_ACTOR",
    "NEXT_ACTION",
    "PROHIBITED_ACTIONS",
    "NEXT_PROMPT",
    "WORKFLOW_FAILURE_ISSUES",
    "LAST_UPDATED",
)

NONE_ALLOWED_FIELDS = frozenset(
    {
        "PR",
        "HEAD_SHA",
        "BASE_OR_MERGE_SHA",
        "BLOCKERS",
        "WORKFLOW_FAILURE_ISSUES",
        "NEXT_PROMPT",
        "NEXT_ACTION",
    }
)

#: States where no PR or head SHA exists yet, so ``none`` is legitimate.
_PRE_PR_STATES = frozenset({"needs-author", "blocked"})

_PLACEHOLDERS = frozenset({"", "none", "n/a", "na", "tbd", "todo", "unknown", "?"})

#: A next prompt short enough to be a stub cannot be "ready to run".
MIN_NEXT_PROMPT_CHARS = 40

_FIELD_LINE_RE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", re.MULTILINE)
_HEADING_RE = re.compile(r"^##\s*Canonical Handoff\s*$", re.IGNORECASE | re.MULTILINE)
_EXTERNAL_CHAT_RE = re.compile(
    r"\b(?:previous chat|prior conversation|earlier conversation|see (?:the )?chat|"
    r"chat history|paste (?:this )?(?:from|into) chatgpt|ask the operator to paste)\b",
    re.IGNORECASE,
)

LIVE_DETECTION_KINDS: tuple[str, ...] = (
    "changed_pr_head",
    "stale_approval",
    "issue_closed",
    "issue_reopened",
    "pr_merged",
    "pr_closed_unmerged",
    "stale_lease",
    "foreign_lease",
    "missing_worktree",
    "dirty_worktree",
    "namespace_mismatch",
    "stale_runtime",
    "changed_base",
    "conflicting_canonical_comments",
)

CONTROLLER_DECISIONS = frozenset(
    {
        "accept",
        "request_tests",
        "request_proof",
        "request_corrections",
        "reopen",
        "return_to_actor",
    }
)

CONTROLLER_CLOSURE_PROOF_FIELDS = (
    "acceptance_criteria_satisfied",
    "cleanup_complete",
    "canonical_final_state_posted",
    "issue_closed_through_workflow",
)

WORKFLOW_FAILURE_FIELDS = (
    "classification",
    "linked_issue",
    "temporary_impact",
    "next_valid_actor",
    "recovery_prompt",
)


def _is_placeholder(value: Any) -> bool:
    return str(value or "").strip().lower() in _PLACEHOLDERS


def _clean(value: Any) -> str:
    text = str(value).strip() if value is not None else ""
    return text or "none"


# ---------------------------------------------------------------------------
# rendering / parsing
# ---------------------------------------------------------------------------


def render_self_propagating_handoff(**values: Any) -> str:
    """Render a canonical cross-role handoff block.

    Raises ``ValueError`` for an unknown workflow state so a malformed handoff
    can never be produced by the sanctioned renderer.
    """
    state = str(values.get("WORKFLOW_STATE", values.get("workflow_state", ""))).strip()
    if state not in WORKFLOW_STATES:
        raise ValueError(
            f"unknown workflow state '{state}'; expected one of {list(WORKFLOW_STATES)}"
        )
    lines = [MARKER, f"## {HANDOFF_HEADING}", "", "```text"]
    for name in HANDOFF_FIELDS:
        raw = values.get(name, values.get(name.lower()))
        lines.append(f"{name}: {_clean(raw)}")
    lines.append("```")
    return "\n".join(lines)


def parse_self_propagating_handoff(text: str) -> dict[str, str] | None:
    """Parse a canonical handoff block, or ``None`` when absent."""
    body = text or ""
    if MARKER not in body and not _HEADING_RE.search(body):
        return None
    fields = {
        match.group(1): match.group(2).strip()
        for match in _FIELD_LINE_RE.finditer(body)
    }
    if not fields:
        return None
    return fields


def handoff_present(text: str) -> bool:
    """Whether *text* carries a canonical handoff block at all."""
    return parse_self_propagating_handoff(text) is not None


# ---------------------------------------------------------------------------
# handoff validation (AC: a validator rejects incomplete handoffs)
# ---------------------------------------------------------------------------


def assess_self_propagating_handoff(text: str) -> dict[str, Any]:
    """Fail closed unless *text* carries one complete canonical handoff."""
    fields = parse_self_propagating_handoff(text)
    if fields is None:
        return {
            "valid": False,
            "block": True,
            "present": False,
            "fields": {},
            "missing_fields": list(HANDOFF_FIELDS),
            "workflow_state": None,
            "next_actor": None,
            "terminal": False,
            "reasons": ["report or comment carries no canonical handoff block"],
            "safe_next_action": (
                "add a canonical handoff block with all "
                f"{len(HANDOFF_FIELDS)} fields before posting"
            ),
        }

    reasons: list[str] = []
    state = (fields.get("WORKFLOW_STATE") or "").strip()
    terminal = state in TERMINAL_STATES

    if state not in WORKFLOW_STATES:
        reasons.append(
            f"unknown WORKFLOW_STATE '{state or 'missing'}'; "
            f"expected one of {list(WORKFLOW_STATES)}"
        )

    missing = [name for name in HANDOFF_FIELDS if name not in fields]
    reasons.extend(f"handoff missing field: {name}" for name in missing)

    for name in HANDOFF_FIELDS:
        if name in missing:
            continue
        value = fields.get(name, "")
        if not _is_placeholder(value):
            continue
        if name in NONE_ALLOWED_FIELDS:
            continue
        # A terminated chain names no next actor by design.
        if name == "NEXT_ACTOR" and terminal:
            continue
        reasons.append(f"handoff field {name} must be concrete, got '{value or ''}'")

    if state and state not in _PRE_PR_STATES and state in WORKFLOW_STATES:
        for name in ("PR", "HEAD_SHA"):
            if name not in missing and _is_placeholder(fields.get(name)):
                reasons.append(
                    f"handoff field {name} must be concrete in state '{state}'"
                )

    if state == "blocked" and _is_placeholder(fields.get("BLOCKERS")):
        reasons.append("state 'blocked' requires a concrete BLOCKERS entry")

    declared_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
    expected_actor = NEXT_ACTOR_BY_STATE.get(state)
    if expected_actor and declared_actor != expected_actor:
        reasons.append(
            f"NEXT_ACTOR '{declared_actor or 'missing'}' does not match state "
            f"'{state}', which authorizes '{expected_actor}'"
        )

    next_prompt = (fields.get("NEXT_PROMPT") or "").strip()
    next_action = (fields.get("NEXT_ACTION") or "").strip()
    if terminal:
        # A completed workflow terminates; it must not manufacture more work.
        if not _is_placeholder(next_prompt):
            reasons.append(
                "terminal state 'complete' must not carry a NEXT_PROMPT; "
                "the chain ends at controller closure"
            )
        if not _is_placeholder(next_action):
            reasons.append(
                "terminal state 'complete' must not carry a NEXT_ACTION"
            )
    else:
        if _is_placeholder(next_prompt):
            reasons.append(
                "non-terminal handoff requires a complete ready-to-run NEXT_PROMPT"
            )
        elif len(next_prompt) < MIN_NEXT_PROMPT_CHARS:
            reasons.append(
                "NEXT_PROMPT is too short to be ready-to-run "
                f"({len(next_prompt)} < {MIN_NEXT_PROMPT_CHARS} characters)"
            )
        if _is_placeholder(next_action):
            reasons.append("non-terminal handoff requires a concrete NEXT_ACTION")

    acting_role = (fields.get("ACTING_ROLE") or "").strip().lower()
    if acting_role and acting_role not in WORKFLOW_ROLES:
        reasons.append(
            f"unknown ACTING_ROLE '{acting_role}'; expected one of "
            f"{sorted(WORKFLOW_ROLES)}"
        )

    block = bool(reasons)
    return {
        "valid": not block,
        "block": block,
        "present": True,
        "fields": fields,
        "missing_fields": missing,
        "workflow_state": state or None,
        "next_actor": declared_actor or None,
        "terminal": terminal,
        "reasons": reasons,
        "safe_next_action": (
            "complete every canonical handoff field before posting"
            if block
            else "proceed"
        ),
    }


def assess_thread_recoverability(text: str) -> dict[str, Any]:
    """The next actor must recover from the thread alone — never outside chat."""
    assessment = assess_self_propagating_handoff(text)
    if assessment["block"]:
        return {
            "recoverable": False,
            "block": True,
            "reasons": assessment["reasons"],
            "safe_next_action": assessment["safe_next_action"],
        }

    fields = assessment["fields"]
    reasons: list[str] = []
    if assessment["terminal"]:
        return {
            "recoverable": True,
            "block": False,
            "reasons": [],
            "safe_next_action": "proceed",
        }

    prompt = fields.get("NEXT_PROMPT", "")
    repository = fields.get("REPOSITORY", "").strip()
    issue = fields.get("ISSUE", "").strip().lstrip("#")

    if repository and repository.lower() not in prompt.lower():
        reasons.append("NEXT_PROMPT must name the repository it applies to")
    if issue and issue not in prompt:
        reasons.append(f"NEXT_PROMPT must name issue {issue}")
    if _EXTERNAL_CHAT_RE.search(prompt):
        reasons.append(
            "NEXT_PROMPT must not depend on outside chat history; the issue or "
            "PR thread, workflow docs, and live repository state must suffice"
        )

    block = bool(reasons)
    return {
        "recoverable": not block,
        "block": block,
        "reasons": reasons,
        "safe_next_action": (
            "rewrite NEXT_PROMPT so it is self-contained" if block else "proceed"
        ),
    }


# ---------------------------------------------------------------------------
# live-state recovery (AC: head changes invalidate stale review/merge handoffs)
# ---------------------------------------------------------------------------


def _detection(kind: str, detail: str) -> dict[str, str]:
    return {"kind": kind, "detail": detail}


def assess_handoff_live_state(
    *,
    handoff: str | Mapping[str, str],
    live: Mapping[str, Any],
) -> dict[str, Any]:
    """Re-derive workflow truth from live state instead of trusting *handoff*.

    *live* carries observed facts; absent keys are simply not checked, but any
    fact that contradicts the inherited handoff fails closed.
    """
    if isinstance(handoff, Mapping):
        fields = dict(handoff)
    else:
        parsed = parse_self_propagating_handoff(handoff or "")
        if parsed is None:
            return {
                "block": True,
                "detections": [_detection("missing_handoff", "no canonical handoff")],
                "kinds": ["missing_handoff"],
                "reasons": ["no canonical handoff to reconcile against live state"],
                "recovered_state": None,
                "safe_next_action": "post a canonical handoff before continuing",
            }
        fields = parsed

    state = (fields.get("WORKFLOW_STATE") or "").strip()
    next_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
    detections: list[dict[str, str]] = []
    recovered_state: str | None = None

    handoff_head = (fields.get("HEAD_SHA") or "").strip()
    live_head = str(live.get("pr_head_sha") or "").strip()
    head_changed = bool(
        live_head and handoff_head and not _is_placeholder(handoff_head)
        and live_head != handoff_head
    )
    if head_changed:
        detections.append(
            _detection(
                "changed_pr_head",
                f"handoff pinned {handoff_head}, live head is {live_head}",
            )
        )
        if next_actor in {"reviewer", "merger"}:
            recovered_state = "needs-review"

    approved_head = str(live.get("approved_head_sha") or "").strip()
    if approved_head and live_head and approved_head != live_head:
        detections.append(
            _detection(
                "stale_approval",
                f"approval recorded at {approved_head}, live head is {live_head}",
            )
        )
        if next_actor == "merger":
            recovered_state = "needs-review"

    issue_state = str(live.get("issue_state") or "").strip().lower()
    if issue_state == "closed" and state not in TERMINAL_STATES:
        detections.append(
            _detection("issue_closed", "linked issue is closed but handoff is not complete")
        )
    if issue_state == "open" and state in TERMINAL_STATES:
        detections.append(
            _detection("issue_reopened", "handoff claims complete but the issue is open")
        )
        recovered_state = "needs-author"

    pr_state = str(live.get("pr_state") or "").strip().lower()
    if pr_state == "merged" and state in {
        "needs-author",
        "needs-review",
        "approved-awaiting-merge",
    }:
        detections.append(
            _detection("pr_merged", "PR is already merged; controller boundary applies")
        )
        recovered_state = "merged-awaiting-controller"
    if pr_state == "closed" and state not in TERMINAL_STATES:
        detections.append(
            _detection("pr_closed_unmerged", "PR is closed without merge")
        )

    lease = live.get("lease") or {}
    if isinstance(lease, Mapping) and lease:
        lease_status = str(lease.get("status") or "").strip().lower()
        if lease_status and lease_status != "active":
            detections.append(
                _detection("stale_lease", f"lease status is '{lease_status}'")
            )
        lease_session = str(lease.get("session_id") or "").strip()
        actor_session = str(live.get("actor_session_id") or "").strip()
        if lease_session and actor_session and lease_session != actor_session:
            detections.append(
                _detection(
                    "foreign_lease",
                    "lease is owned by another session; never adopt it implicitly",
                )
            )

    worktree = live.get("worktree") or {}
    if isinstance(worktree, Mapping) and worktree:
        if worktree.get("present") is False:
            detections.append(_detection("missing_worktree", "bound worktree is absent"))
        if worktree.get("dirty") is True:
            detections.append(
                _detection("dirty_worktree", "bound worktree carries uncommitted changes")
            )

    namespace_role = str(live.get("namespace_role") or "").strip().lower()
    if namespace_role and next_actor and next_actor != "none":
        if namespace_role != next_actor:
            detections.append(
                _detection(
                    "namespace_mismatch",
                    f"live namespace role '{namespace_role}' cannot act as '{next_actor}'",
                )
            )

    if live.get("runtime_stale") is True:
        detections.append(
            _detection("stale_runtime", "serving runtime is stale; reconnect required")
        )

    handoff_base = (fields.get("BASE_BRANCH") or "").strip()
    live_base = str(live.get("base_branch") or "").strip()
    if handoff_base and live_base and not _is_placeholder(handoff_base):
        if handoff_base != live_base:
            detections.append(
                _detection(
                    "changed_base",
                    f"handoff base '{handoff_base}' but live base '{live_base}'",
                )
            )

    if live.get("conflicting_canonical_comments") is True:
        detections.append(
            _detection(
                "conflicting_canonical_comments",
                "thread carries contradictory canonical comments",
            )
        )

    kinds = [item["kind"] for item in detections]
    reasons = [f"{item['kind']}: {item['detail']}" for item in detections]
    block = bool(detections)
    return {
        "block": block,
        "detections": detections,
        "kinds": kinds,
        "reasons": reasons,
        "recovered_state": recovered_state,
        "safe_next_action": (
            "post a corrected canonical handoff for the recovered live state "
            "before acting"
            if block
            else "proceed"
        ),
    }


# ---------------------------------------------------------------------------
# role-limited continuation
# ---------------------------------------------------------------------------


def assess_role_continuation(
    *,
    handoff: str | Mapping[str, str],
    actor_role: str,
) -> dict[str, Any]:
    """Only the role the current state authorizes may continue the chain."""
    if isinstance(handoff, Mapping):
        fields = dict(handoff)
    else:
        fields = parse_self_propagating_handoff(handoff or "") or {}

    role = (actor_role or "").strip().lower()
    state = (fields.get("WORKFLOW_STATE") or "").strip()
    expected = NEXT_ACTOR_BY_STATE.get(state)
    reasons: list[str] = []

    if not fields:
        reasons.append("no canonical handoff to continue from")
    if role not in WORKFLOW_ROLES:
        reasons.append(f"unknown actor role '{actor_role}'")
    if expected is None and fields:
        reasons.append(f"unknown workflow state '{state}'")
    elif expected == "none":
        reasons.append(
            "workflow state 'complete' is terminal; no further role may continue"
        )
    elif expected and role != expected:
        reasons.append(
            f"state '{state}' authorizes '{expected}', not '{role}'"
        )

    block = bool(reasons)
    return {
        "allowed": not block,
        "block": block,
        "expected_actor": expected,
        "actor_role": role,
        "allowed_actions": () if block else ROLE_ALLOWED_ACTIONS.get(role, ()),
        "forbidden_actions": ROLE_FORBIDDEN_ACTIONS.get(role, ()),
        "reasons": reasons,
        "safe_next_action": (
            f"hand off to '{expected}'" if block and expected else
            "stop; the workflow is complete" if expected == "none" else
            "proceed"
        ),
    }


# ---------------------------------------------------------------------------
# durable posting (AC: a chat-only report is never sufficient)
# ---------------------------------------------------------------------------


def assess_durable_state_update(
    *,
    handoff_text: str,
    posted_comment_id: Any = None,
    canonical_state_posted: bool = False,
) -> dict[str, Any]:
    """A successful actor session must leave the handoff in Gitea, not chat."""
    reasons: list[str] = []
    assessment = assess_self_propagating_handoff(handoff_text)
    if assessment["block"]:
        reasons.extend(assessment["reasons"])
    if not posted_comment_id:
        reasons.append(
            "canonical handoff was not posted to Gitea; a chat-only report is "
            "not durable workflow state"
        )
    if not canonical_state_posted:
        reasons.append(
            "canonical issue/PR state and thread ledger were not updated"
        )

    block = bool(reasons)
    return {
        "durable": not block,
        "block": block,
        "posted_comment_id": posted_comment_id,
        "reasons": reasons,
        "safe_next_action": (
            "post the canonical handoff and state update to Gitea before "
            "ending the session"
            if block
            else "proceed"
        ),
    }


# ---------------------------------------------------------------------------
# merge -> controller boundary and controller continuation
# ---------------------------------------------------------------------------


def assess_merge_completion_transition(
    *,
    merge_succeeded: bool,
    controller_auto_accept: bool = False,
) -> dict[str, Any]:
    """A merged PR is not accepted work until the controller says so."""
    if not merge_succeeded:
        return {
            "next_state": "approved-awaiting-merge",
            "next_actor": "merger",
            "next_prompt_required": True,
            "reasons": ["merge did not succeed; the merger retains the work item"],
        }
    if controller_auto_accept:
        return {
            "next_state": "complete",
            "next_actor": "none",
            "next_prompt_required": False,
            "reasons": ["configured workflow authorizes automatic acceptance on merge"],
        }
    return {
        "next_state": "merged-awaiting-controller",
        "next_actor": "controller",
        "next_prompt_required": True,
        "reasons": [
            "merge succeeded; acceptance requires the authorized controller"
        ],
    }


def assess_controller_decision(
    *,
    decision: str,
    closure_proof: Mapping[str, Any] | None = None,
    return_to: str | None = None,
) -> dict[str, Any]:
    """Controller acceptance or rejection produces the next or final state."""
    normalized = (decision or "").strip().lower()
    if normalized not in CONTROLLER_DECISIONS:
        return {
            "block": True,
            "next_state": None,
            "next_actor": None,
            "next_prompt_required": True,
            "reasons": [
                f"unknown controller decision '{decision}'; expected one of "
                f"{sorted(CONTROLLER_DECISIONS)}"
            ],
            "safe_next_action": "record a supported controller decision",
        }

    if normalized == "accept":
        proof = dict(closure_proof or {})
        missing = [
            name
            for name in CONTROLLER_CLOSURE_PROOF_FIELDS
            if proof.get(name) is not True
        ]
        if missing:
            return {
                "block": True,
                "next_state": "merged-awaiting-controller",
                "next_actor": "controller",
                "next_prompt_required": True,
                "reasons": [
                    "controller acceptance missing closure proof: " + ", ".join(missing)
                ],
                "safe_next_action": (
                    "satisfy and record every closure proof field before closing"
                ),
            }
        return {
            "block": False,
            "next_state": "complete",
            "next_actor": "none",
            "next_prompt_required": False,
            "reasons": ["controller accepted; workflow chain terminates"],
            "safe_next_action": "post the final canonical state and stop",
        }

    if normalized == "return_to_actor":
        target = (return_to or "").strip().lower()
        state_by_actor = {
            "author": "needs-author",
            "reviewer": "needs-review",
            "merger": "approved-awaiting-merge",
        }
        if target not in state_by_actor:
            return {
                "block": True,
                "next_state": None,
                "next_actor": None,
                "next_prompt_required": True,
                "reasons": [
                    f"return_to_actor requires a target in {sorted(state_by_actor)}"
                ],
                "safe_next_action": "name the actor the work returns to",
            }
        return {
            "block": False,
            "next_state": state_by_actor[target],
            "next_actor": target,
            "next_prompt_required": True,
            "reasons": [f"controller returned the work item to '{target}'"],
            "safe_next_action": f"post a complete handoff for '{target}'",
        }

    # request_tests / request_proof / request_corrections / reopen
    return {
        "block": False,
        "next_state": "needs-author",
        "next_actor": "author",
        "next_prompt_required": True,
        "reasons": [f"controller decision '{normalized}' returns the work to the author"],
        "safe_next_action": "post a complete author handoff describing what is required",
    }


# ---------------------------------------------------------------------------
# workflow-failure escalation
# ---------------------------------------------------------------------------


def assess_workflow_failure_escalation(
    *,
    failures: Sequence[Mapping[str, Any]] | None,
    active_issue_number: int | str | None,
    existing_failure_issues: Iterable[Mapping[str, Any]] | None = None,
) -> dict[str, Any]:
    """Tooling defects hit while working an issue become separate durable work."""
    entries = list(failures or [])
    known = {
        str((item.get("signature") or "")).strip().lower(): item.get("number")
        for item in (existing_failure_issues or [])
        if str((item.get("signature") or "")).strip()
    }
    active = str(active_issue_number or "").strip().lstrip("#")

    reasons: list[str] = []
    reused: list[dict[str, Any]] = []
    seen_signatures: dict[str, str] = {}

    for index, failure in enumerate(entries):
        label = str(failure.get("signature") or f"failure[{index}]")
        missing = [
            name
            for name in WORKFLOW_FAILURE_FIELDS
            if _is_placeholder(failure.get(name))
        ]
        if missing:
            reasons.append(
                f"{label}: workflow failure missing " + ", ".join(missing)
            )

        linked = str(failure.get("linked_issue") or "").strip().lstrip("#")
        if linked and active and linked == active:
            reasons.append(
                f"{label}: workflow defects must not be folded into the active "
                f"work item #{active}; file a separate durable issue"
            )

        signature = str(failure.get("signature") or "").strip().lower()
        if not signature:
            continue
        if signature in known:
            expected = str(known[signature] or "").strip().lstrip("#")
            if linked and expected and linked != expected:
                reasons.append(
                    f"{label}: duplicate workflow-failure issue #{linked}; "
                    f"reuse the existing issue #{expected}"
                )
            else:
                reused.append({"signature": signature, "issue": expected})
        if signature in seen_signatures:
            reasons.append(
                f"{label}: duplicate workflow-failure signature reported twice "
                "in one session"
            )
        else:
            seen_signatures[signature] = linked

    block = bool(reasons)
    return {
        "escalated": not block,
        "block": block,
        "failure_count": len(entries),
        "reused_issues": reused,
        "reasons": reasons,
        "safe_next_action": (
            "file or reference one durable issue per distinct workflow failure"
            if block
            else "proceed"
        ),
    }


# ---------------------------------------------------------------------------
# final-report integration
# ---------------------------------------------------------------------------


def assess_final_report_self_propagating_handoff(report_text: str) -> dict[str, Any]:
    """#626 gate for final reports.

    Applicability mirrors the #495 canonical-state gate: once a report adopts
    the protocol — by carrying the marker, the ``Canonical Handoff`` heading,
    or a ``WORKFLOW_STATE`` line — the full schema is enforced.  Reports that
    predate the protocol are untouched here; the workflow schemas require the
    block going forward.
    """
    text = report_text or ""
    applicable = (
        MARKER in text
        or bool(_HEADING_RE.search(text))
        or bool(re.search(r"^WORKFLOW_STATE\s*:", text, re.MULTILINE))
    )
    if not applicable:
        return {
            "applicable": False,
            "valid": True,
            "block": False,
            "reasons": [],
            "safe_next_action": "proceed",
        }

    assessment = assess_self_propagating_handoff(text)
    recoverability = assess_thread_recoverability(text)
    reasons = list(assessment["reasons"])
    if not assessment["block"]:
        reasons.extend(recoverability.get("reasons") or [])
    block = bool(assessment["block"] or recoverability.get("block"))
    return {
        "applicable": True,
        "valid": not block,
        "block": block,
        "workflow_state": assessment.get("workflow_state"),
        "next_actor": assessment.get("next_actor"),
        "terminal": assessment.get("terminal"),
        "reasons": reasons,
        "safe_next_action": (
            assessment["safe_next_action"]
            if assessment["block"]
            else recoverability.get("safe_next_action", "proceed")
        ),
    }
