#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
# pulse-merge-feedback.sh — Worker-PR feedback routing for the deterministic merge pass.
#
# Extracted from pulse-merge.sh (GH#19836) to bring that file below the
# 2000-line simplification gate.
#
# This module contains the "route feedback to linked issue + close PR"
# cluster: the three dispatch helpers invoked by _check_pr_merge_gates
# when a worker-authored PR hits a dead-end state (CI red, conflicts
# unresolvable by update-branch, or CHANGES_REQUESTED review). Each
# helper appends a feedback section to the linked issue body (marker-
# guarded for idempotency), transitions the issue to status:available,
# and closes the PR so the dispatch queue can re-pick the work.
#
# None of these functions call back into the merge core or pr-gates
# clusters — they only call low-level `gh` commands, `set_issue_status`
# from shared-constants.sh, and the local `_build_review_feedback_section`
# helper. Safe to extract into its own module.
#
# This module is sourced by pulse-wrapper.sh AFTER pulse-merge.sh and
# pulse-merge-conflict.sh. It MUST NOT be executed directly — it relies
# on the orchestrator having sourced shared-constants.sh and having
# defined all PULSE_* configuration constants in the bootstrap section.
#
# Functions in this module (in source order):
#   - _build_review_feedback_section      (t2093)
#   - _append_feedback_to_issue           (GH#20057, shared helper)
#   - _transition_issue_for_redispatch    (GH#20057, shared helper)
#   - _close_and_label_feedback_pr        (GH#20057, shared helper)
#   - _build_ci_feedback_section          (GH#20057, extracted builder)
#   - _dispatch_ci_fix_worker             (t2093 follow-up)
#   - _classify_conflicts_by_pattern      (t2987, pattern classifier)
#   - _emit_pattern_guidance_blocks       (t2987, guidance emitter)
#   - _build_conflict_feedback_section    (t2426, extracted builder)
#   - _dispatch_conflict_fix_worker       (t2093 follow-up)
#   - _dispatch_pr_fix_worker             (t2093)
#
# All functions fail-open: missing helpers, API errors, or malformed
# state never block the merge pass — they log and return 0.

# Include guard — prevent double-sourcing.
[[ -n "${_PULSE_MERGE_FEEDBACK_LOADED:-}" ]] && return 0
_PULSE_MERGE_FEEDBACK_LOADED=1

# t2863: Module-level variable defaults (set -u guards).
# Ensures LOGFILE is safe to dereference in all functions when this module
# is sourced outside the pulse-wrapper.sh bootstrap context.
: "${LOGFILE:=${HOME}/.aidevops/logs/pulse.log}"

#######################################
# Build the markdown "Review Feedback" section for routing to a linked
# issue (t2093).
#
# Reads already-fetched review + inline-comment JSON arrays and produces a
# human-readable section with file:line citations. The section is scoped
# to a single closing PR so the marker in `_dispatch_pr_fix_worker` can
# prevent duplicate appends if the merge pass re-encounters the same PR
# before the close propagates.
#
# Args:
#   $1 - pr_number
#   $2 - repo_slug
#   $3 - reviews_json    (JSON array of {author,state,body,url})
#   $4 - inline_json     (JSON array of {author,path,line,body,url})
#
# Output: markdown section on stdout (empty string if no content).
#######################################
_build_review_feedback_section() {
	local pr_number="$1"
	local repo_slug="$2"
	local reviews_json="${3:-[]}"
	local inline_json="${4:-[]}"

	local reviews_count="" inline_count=""
	reviews_count=$(printf '%s' "$reviews_json" | jq 'length' 2>/dev/null) || reviews_count=0
	inline_count=$(printf '%s' "$inline_json" | jq 'length' 2>/dev/null) || inline_count=0
	[[ "$reviews_count" =~ ^[0-9]+$ ]] || reviews_count=0
	[[ "$inline_count" =~ ^[0-9]+$ ]] || inline_count=0

	if [[ "$reviews_count" -eq 0 && "$inline_count" -eq 0 ]]; then
		return 0
	fi

	local header
	header="## Review Feedback routed from PR #${pr_number} (t2093)

This section was auto-generated when the deterministic merge pass detected
\`reviewDecision=CHANGES_REQUESTED\` on the linked worker PR. The PR has been
closed and this issue re-entered the dispatch queue. The next worker should
address the findings below and open a fresh PR against this issue.

See the original PR for full context: https://github.com/${repo_slug}/pull/${pr_number}
"

	local reviews_md=""
	if [[ "$reviews_count" -gt 0 ]]; then
		reviews_md=$(printf '%s' "$reviews_json" | jq -r '
			.[] | "- **@\(.author)** (`\(.state)`): \(((.body // "") | gsub("\r"; "") | split("\n")[0])[0:300])\n  [view review](\(.url // ""))"
		' 2>/dev/null) || reviews_md=""
	fi

	local inline_md=""
	if [[ "$inline_count" -gt 0 ]]; then
		inline_md=$(printf '%s' "$inline_json" | jq -r '
			.[] | "- **@\(.author)** `\(.path)`:\(.line // "?") — \(((.body // "") | gsub("\r"; "") | split("\n")[0])[0:300])\n  [view comment](\(.url // ""))"
		' 2>/dev/null) || inline_md=""
	fi

	printf '%s\n' "$header"
	if [[ -n "$reviews_md" ]]; then
		printf '### Top-level reviews\n\n%s\n\n' "$reviews_md"
	fi
	if [[ -n "$inline_md" ]]; then
		printf '### Inline comments (file:line citations)\n\n%s\n\n' "$inline_md"
	fi
	return 0
}

#######################################
# Append a feedback section to a linked issue body, guarded by a marker
# comment for idempotency and with a t2383 fail-safe against body
# clobbering when the issue fetch fails.
#
# Shared by _dispatch_ci_fix_worker, _dispatch_conflict_fix_worker, and
# _dispatch_pr_fix_worker.
#
# Args:
#   $1 - linked_issue  (issue number)
#   $2 - repo_slug     (owner/repo)
#   $3 - marker        (HTML comment marker string)
#   $4 - feedback_section (markdown to append)
#   $5 - caller        (calling function name, for log messages)
#
# Returns: 0 on success or skip (already present), 1 on failure.
#######################################
_append_feedback_to_issue() {
	local linked_issue="$1"
	local repo_slug="$2"
	local marker="$3"
	local feedback_section="$4"
	local caller="$5"

	# t2383 Fix 5: fail-safe — skip body edit when issue fetch fails to
	# prevent clobbering the issue body with only the routed-feedback section.
	local current_body="" fetch_rc=""
	fetch_rc=0
	current_body=$(gh issue view "$linked_issue" --repo "$repo_slug" \
		--json body --jq '.body // ""' 2>/dev/null) || fetch_rc=$?
	if [[ $fetch_rc -ne 0 ]]; then
		echo "[pulse-wrapper] ${caller}: failed to fetch issue #${linked_issue} body (exit ${fetch_rc}) — skipping body edit to prevent data loss (t2383)" >>"$LOGFILE"
		return 1
	fi

	if printf '%s' "$current_body" | grep -qF "$marker"; then
		# Keep the "routed feedback marker" phrase stable for operator log
		# greps and regression tests (GH#20057): the pre-split dispatch
		# functions all logged a variant of "already has … feedback …".
		echo "[pulse-wrapper] ${caller}: issue #${linked_issue} already has routed feedback marker for this PR — skipping" >>"$LOGFILE"
		return 0
	fi

	local new_body="${current_body}

${marker}
${feedback_section}"
	# Use gh_issue_edit_safe (not bare `gh issue edit`) so the REST fallback
	# in shared-gh-wrappers-safe-edit.sh fires when GraphQL is rate-limited.
	# Bare `gh issue edit` always uses GraphQL and silently fails the body
	# update when the 5000/hr GraphQL budget is exhausted. PR #21733 model.
	gh_issue_edit_safe "$linked_issue" --repo "$repo_slug" \
		--body "$new_body" >/dev/null 2>&1 || {
		echo "[pulse-wrapper] ${caller}: failed to update issue #${linked_issue} body — aborting" >>"$LOGFILE"
		return 1
	}
	return 0
}

#######################################
# Transition a linked issue to status:available and add a source label
# so the dispatch queue can re-pick the work.
#
# Uses set_issue_status when available (atomically clears other status
# labels), falls back to direct gh label ops in degraded environments.
#
# Args:
#   $1 - linked_issue  (issue number)
#   $2 - repo_slug     (owner/repo)
#   $3 - source_label  (e.g. "source:ci-feedback")
#######################################
_transition_issue_for_redispatch() {
	local linked_issue="$1"
	local repo_slug="$2"
	local source_label="$3"
	local _assignees=""
	_assignees=$(gh issue view "$linked_issue" --repo "$repo_slug" --json assignees --jq '.assignees[].login' 2>/dev/null) || _assignees=""

	local -a _redispatch_flags=(
		--add-label "origin:worker"
		--remove-label "origin:interactive"
		--remove-label "origin:worker-takeover"
	)
	local _assignee
	while IFS= read -r _assignee; do
		[[ -n "$_assignee" ]] && _redispatch_flags+=(--remove-assignee "$_assignee")
	done <<<"$_assignees"

	if declare -F set_issue_status >/dev/null 2>&1; then
		set_issue_status "$linked_issue" "$repo_slug" "available" \
			--add-label "$source_label" "${_redispatch_flags[@]}" >/dev/null 2>&1 || true
	else
		gh issue edit "$linked_issue" --repo "$repo_slug" \
			--add-label "status:available" --add-label "$source_label" \
			"${_redispatch_flags[@]}" \
			--remove-label "status:queued" --remove-label "status:in-progress" \
			--remove-label "status:in-review" --remove-label "status:claimed" \
			>/dev/null 2>&1 || true
	fi
	return 0
}

#######################################
# Close a feedback-routed PR with an explanatory comment and apply an
# idempotency label.
#
# Args:
#   $1 - pr_number
#   $2 - repo_slug
#   $3 - close_comment  (markdown body for the close comment)
#   $4 - label          (e.g. "ci-feedback-routed")
#######################################
_close_and_label_feedback_pr() {
	local pr_number="$1"
	local repo_slug="$2"
	local close_comment="$3"
	local label="$4"

	gh pr close "$pr_number" --repo "$repo_slug" \
		--comment "$close_comment" >/dev/null 2>&1 || true
	gh pr edit "$pr_number" --repo "$repo_slug" \
		--add-label "$label" >/dev/null 2>&1 || true
	return 0
}

#######################################
# Build the markdown "CI Failure Feedback" section for routing to a
# linked issue.
#
# Args:
#   $1 - pr_number
#   $2 - failing_checks       (markdown list of terminal failed check names/conclusions/URLs)
#   $3 - classification_output (optional, t3225 — multi-line "CLASS names";
#        triggers a Pattern-Specific Resolution Guidance subsection BEFORE
#        the generic worker guidance when any non-OTHER class is present)
#
# Output: markdown section on stdout.
#######################################
_build_ci_feedback_section() {
	local pr_number="$1"
	local failing_checks="$2"
	local classification_output="${3:-}"

	# Locate the ci-failure-patterns.conf registry (t3225) so the guidance
	# emitter can look up resolution commands per classification. Use
	# dirname (not cd+pwd) — t3225 string-literal ratchet avoidance.
	local conf_file
	conf_file="${BASH_SOURCE[0]%/*}/../configs/ci-failure-patterns.conf"

	# Lead with header + terminal failed checks list (always present).
	cat <<-EOF
		## CI Repair Feedback (from PR #${pr_number})

		The previous worker's PR #${pr_number} had terminal failed CI checks. The PR has been
		closed and this issue re-queued for dispatch. The next worker should address these failures.

		### Terminal failed checks

		${failing_checks}
	EOF

	# Insert pattern-specific guidance blocks BEFORE the generic worker
	# guidance, so the auto-fix sequences are seen first (t3225).
	if [[ -n "$classification_output" ]]; then
		_emit_ci_failure_guidance_blocks "$classification_output" "$conf_file"
	fi

	# Generic worker guidance (always emitted as a fallback).
	cat <<-EOF
		### Worker guidance

		1. Check out a fresh branch from \`origin/main\` (do NOT reuse the old branch)
		2. Read the terminal check URLs above for specific error messages
		3. Fix the issues in the code, not in the CI config
		4. Ensure all checks pass locally before pushing

		_Routed by deterministic merge pass (pulse-merge.sh)._
	EOF
	return 0
}

#######################################
# Return whether a failed check URL points at a GitHub Actions job whose failed
# log is an infrastructure timeout/kill rather than actionable code feedback.
#
# Args:
#   $1 - repo_slug
#   $2 - check URL
#
# Returns: 0=infra timeout/kill detected, 1=not detected or unavailable.
#######################################
_ci_check_url_has_infra_timeout_log() {
	local repo_slug="$1"
	local check_url="$2"

	[[ -n "$repo_slug" ]] || return 1
	[[ -n "$check_url" ]] || return 1

	local run_id="" job_id=""
	case "$check_url" in
	*"/actions/runs/"*"/job/"*) ;;
	*) return 1 ;;
	esac

	run_id="${check_url#*/actions/runs/}"
	run_id="${run_id%%/*}"
	job_id="${check_url#*/job/}"
	job_id="${job_id%%[/?#]*}"
	[[ "$run_id" =~ ^[0-9]+$ ]] || return 1
	[[ "$job_id" =~ ^[0-9]+$ ]] || return 1

	local failed_log=""
	failed_log=$(gh run view "$run_id" --repo "$repo_slug" --job "$job_id" --log-failed 2>/dev/null) || failed_log=""
	[[ -n "$failed_log" ]] || return 1

	if printf '%s\n' "$failed_log" | grep -Eiq '(Process completed with exit code (124|137|143)|timed out after|[[:space:]]Killed[[:space:]]+timeout|timeout --kill-after|The operation was canceled|cancelled due to timeout)'; then
		return 0
	fi
	return 1
}

#######################################
# Filter required failed checks down to actionable code failures by excluding
# GitHub Actions jobs whose logs show timeout/kill infrastructure signatures.
#
# Args:
#   $1 - pr_number
#   $2 - repo_slug
#   $3 - checks_json (array of {name, conclusion, link})
#
# Output: markdown list of actionable checks.
#######################################
_ci_actionable_failed_checks_markdown() {
	local pr_number="$1"
	local repo_slug="$2"
	local checks_json="$3"

	local count=""
	count=$(printf '%s' "$checks_json" | jq 'length' 2>/dev/null) || count=0
	[[ "$count" =~ ^[0-9]+$ ]] || count=0
	[[ "$count" -gt 0 ]] || return 0

	local idx=0 name="" conclusion="" link=""
	while [[ "$idx" -lt "$count" ]]; do
		name=$(printf '%s' "$checks_json" | jq -r --argjson i "$idx" '.[$i].name // empty' 2>/dev/null) || name=""
		conclusion=$(printf '%s' "$checks_json" | jq -r --argjson i "$idx" '.[$i].conclusion // empty' 2>/dev/null) || conclusion=""
		link=$(printf '%s' "$checks_json" | jq -r --argjson i "$idx" '.[$i].link // empty' 2>/dev/null) || link=""
		if _ci_check_url_has_infra_timeout_log "$repo_slug" "$link"; then
			echo "[pulse-wrapper] _dispatch_ci_fix_worker: PR #${pr_number} check '${name}' classified as infra-timeout from failed log — skipping code redispatch" >>"$LOGFILE"
		else
			printf -- '- **%s**: %s — [check URL](%s)\n' "$name" "$conclusion" "$link"
		fi
		idx=$((idx + 1))
	done
	return 0
}

#######################################
# Return terminal failed check details and names from one jq pass.
#
# Args:
#   $1 - checks_json (array from gh pr checks --json name,bucket,state,link)
#   $2 - terminal_failed_check_filter (jq select expression)
#
# Output: first line is filtered checks JSON, followed by a marker and one
# check name per line. Callers split this without re-running jq over the same
# payload.
#######################################
_ci_terminal_failed_check_results() {
	local checks_json="$1"
	local terminal_failed_check_filter="$2"

	[[ -n "$checks_json" ]] || checks_json="[]"
	printf '%s' "$checks_json" | jq -r "([.[] | select(${terminal_failed_check_filter}) | {name, conclusion: ((.conclusion // .state // \"\") | ascii_downcase), link}] | tojson), \"__AIDEVOPS_CHECK_NAMES__\", (.[] | select(${terminal_failed_check_filter}) | .name)" 2>/dev/null || {
		printf '[]\n__AIDEVOPS_CHECK_NAMES__\n'
		return 0
	}
	return 0
}

#######################################
# Route CI failure feedback from a worker/trusted PR to its linked issue, close
# the PR, and set the issue to status:available for re-dispatch.
#
# The next worker sees the failing check names, URLs, and context in the
# issue body and can address the failures directly. The marker is keyed by
# PR + head SHA so repeated pulse cycles do not duplicate repair work for the
# same failing commit, while a newly-pushed head can be routed again if it is
# still red.
#
# Same pattern as _dispatch_pr_fix_worker (t2093) but for CI failures
# instead of review CHANGES_REQUESTED.
#
# Args: $1=pr_number, $2=repo_slug, $3=linked_issue, $4=checks_json (optional)
#######################################
_dispatch_ci_fix_worker() {
	local pr_number="$1"
	local repo_slug="$2"
	local linked_issue="$3"
	local supplied_checks_json="${4:-}"

	[[ "$pr_number" =~ ^[0-9]+$ ]] || return 0
	[[ -n "$repo_slug" ]] || return 0
	[[ "$linked_issue" =~ ^[0-9]+$ ]] || return 0

	# Create labels (idempotent, --force)
	gh label create "ci-feedback-routed" --repo "$repo_slug" --color "E4E669" \
		--description "Worker PR with failing CI routed to linked issue for re-dispatch" \
		--force >/dev/null 2>&1 || true
	gh label create "source:ci-feedback" --repo "$repo_slug" --color "FEF2C0" \
		--description "Issue carries CI failure feedback routed from a closed worker PR" \
		--force >/dev/null 2>&1 || true

	# Collect actionable failed required checks first. Pending/queued/in-progress
	# checks are not actionable repair evidence and must not be routed into the
	# linked issue as stale worker guidance. Likewise, cancelled/timed_out checks
	# usually reflect CI capacity, superseded runs, or job-budget kills; routing
	# those as code-fix feedback creates duplicate PR churn instead of retrying or
	# escalating CI infrastructure. If required checks contain no actionable
	# failures and the caller did not provide precomputed checks, fall back to the
	# full check set so advisory-only terminal failures can still carry
	# pattern-specific repair guidance.
	local terminal_failed_check_filter
	terminal_failed_check_filter='(.bucket == "fail" or .bucket == "cancel") and (((.conclusion // .state // "") | ascii_downcase) | test("^(failure|action_required)$")) and ((.link // "") != "")'
	local checks_json="$supplied_checks_json" result_marker=$'\n__AIDEVOPS_CHECK_NAMES__'
	local check_results="" failing_checks_json="" failing_checks="" failing_names="" classification_output=""
	if [[ -z "$checks_json" ]]; then
		checks_json=$(gh pr checks "$pr_number" --repo "$repo_slug" --required \
			--json name,bucket,state,link \
			2>/dev/null) || checks_json="[]"
	fi
	check_results=$(_ci_terminal_failed_check_results "$checks_json" "$terminal_failed_check_filter")
	failing_checks_json="${check_results%%"$result_marker"*}"
	failing_names="${check_results#*"$result_marker"}"
	[[ "$failing_names" != "$check_results" ]] || failing_names=""
	failing_names="${failing_names#$'\n'}"
	failing_checks=$(_ci_actionable_failed_checks_markdown "$pr_number" "$repo_slug" "$failing_checks_json")

	if [[ -z "$failing_checks" && -z "$supplied_checks_json" ]]; then
		checks_json=$(gh pr checks "$pr_number" --repo "$repo_slug" \
			--json name,bucket,state,link \
			2>/dev/null) || checks_json="[]"
		check_results=$(_ci_terminal_failed_check_results "$checks_json" "$terminal_failed_check_filter")
		failing_checks_json="${check_results%%"$result_marker"*}"
		failing_names="${check_results#*"$result_marker"}"
		[[ "$failing_names" != "$check_results" ]] || failing_names=""
		failing_names="${failing_names#$'\n'}"
		failing_checks=$(_ci_actionable_failed_checks_markdown "$pr_number" "$repo_slug" "$failing_checks_json")
	fi

	if [[ -z "$failing_checks" ]]; then
		echo "[pulse-wrapper] _dispatch_ci_fix_worker: PR #${pr_number} in ${repo_slug} has no actionable failed checks with URLs — skipping CI repair routing" >>"$LOGFILE"
		return 0
	fi

	# t3225: Also collect raw failing check NAMES (one per line) for
	# pattern classification. Failure to collect names is non-fatal — we
	# fall back to the pre-t3225 behaviour (no pattern guidance block).
	if [[ -n "$failing_names" ]]; then
		classification_output=$(_classify_ci_failures_by_pattern "$failing_names" 2>/dev/null) || classification_output=""
	fi

	# Key dedup by PR + head SHA. A repeated pulse cycle on the same failing
	# commit sees the same marker and skips; a new push gets a new marker and
	# can re-enter repair if it is still blocked.
	local pr_head_sha=""
	pr_head_sha=$(gh pr view "$pr_number" --repo "$repo_slug" \
		--json headRefOid --jq '.headRefOid // ""' 2>/dev/null) || pr_head_sha=""
	[[ -n "$pr_head_sha" ]] || pr_head_sha="unknown"

	# Build the CI Failure Feedback section (with optional pattern guidance).
	local feedback_section
	feedback_section=$(_build_ci_feedback_section "$pr_number" "$failing_checks" "$classification_output")

	# Append to issue body (marker-guarded, t2383 fail-safe)
	local marker="<!-- ci-feedback:PR${pr_number}:SHA${pr_head_sha} -->"
	local current_body=""
	current_body=$(gh issue view "$linked_issue" --repo "$repo_slug" \
		--json body --jq '.body // ""' 2>/dev/null) || current_body=""
	if [[ -n "$current_body" ]] && printf '%s' "$current_body" | grep -qF "$marker"; then
		echo "[pulse-wrapper] _dispatch_ci_fix_worker: issue #${linked_issue} already has CI repair marker for PR #${pr_number} head ${pr_head_sha} — skipping duplicate dispatch (t3508)" >>"$LOGFILE"
		return 0
	fi
	_append_feedback_to_issue "$linked_issue" "$repo_slug" "$marker" \
		"$feedback_section" "_dispatch_ci_fix_worker" || return 0

	# Transition issue to available for re-dispatch
	_transition_issue_for_redispatch "$linked_issue" "$repo_slug" "source:ci-feedback"

	# Close the PR with feedback summary
	_close_and_label_feedback_pr "$pr_number" "$repo_slug" \
		"## CI repair feedback routed to issue #${linked_issue}

This worker PR had terminal failed CI checks. The check details have been appended
to the linked issue body so the next worker can address them.

Terminal failed checks:
${failing_checks}

_Closed by deterministic merge pass (pulse-merge.sh)._" \
		"ci-feedback-routed"

	echo "[pulse-wrapper] _dispatch_ci_fix_worker: routed CI failure feedback from PR #${pr_number} to issue #${linked_issue} in ${repo_slug}" >>"$LOGFILE"
	return 0
}

#######################################
# Build a whole-token lookup set for add/add conflict paths (t3199).
#
# Args:
#   $1 - (optional) path to a git repo with an in-flight rebase/merge.
#
# Output: space-padded path set suitable for `[[ "$set" == *" $path "* ]]`.
#######################################
_conflict_add_add_path_set() {
	local repo_path="${1:-}"
	local add_add_files=""

	if [[ -n "$repo_path" ]] && [[ -d "$repo_path/.git" || -f "$repo_path/.git" ]]; then
		add_add_files=$(git -C "$repo_path" status --porcelain 2>/dev/null \
			| awk '/^AA / {print $2}' | tr '\n' ' ')
		add_add_files="${add_add_files% }"
	fi

	if [[ -n "$add_add_files" ]]; then
		printf ' %s ' "$add_add_files"
	else
		printf ' '
	fi
	return 0
}

#######################################
# Check whether a path is present in a space-padded lookup set.
#
# Args:
#   $1 - file path
#   $2 - space-padded lookup set
#######################################
_conflict_path_in_set() {
	local fpath="$1"
	local lookup_set="$2"

	[[ "$lookup_set" == *" ${fpath} "* ]] || return 1
	return 0
}

#######################################
# Match one conflicting path against the conflict pattern registry.
#
# Args:
#   $1 - file path
#   $2 - conflict-patterns.conf path
#
# Output: matching classification, or CODE.
#######################################
_conflict_registry_class_for_path() {
	local fpath="$1"
	local conf_file="$2"
	local fname
	fname="${fpath##*/}"
	local matched_class=""

	while IFS='|' read -r class_raw glob_raw _rest; do
		local class="" glob=""
		class="${class_raw#"${class_raw%%[![:space:]]*}"}"
		class="${class%"${class##*[![:space:]]}"}"
		glob="${glob_raw#"${glob_raw%%[![:space:]]*}"}"
		glob="${glob%"${glob##*[![:space:]]}"}"

		[[ -n "$class" && -n "$glob" ]] || continue
		[[ "$class" == \#* ]] && continue
		[[ "$class" == "ADD_ADD_NEW_FILE" ]] && continue

		local did_match=0
		# shellcheck disable=SC2254  # dynamic glob is intentional
		case "$fpath" in
			$glob) did_match=1 ;;
		esac
		if [[ $did_match -eq 0 ]]; then
			# shellcheck disable=SC2254  # dynamic glob is intentional
			case "$fname" in
				$glob) did_match=1 ;;
			esac
		fi

		if [[ $did_match -eq 1 ]]; then
			matched_class="$class"
			break
		fi
	done < <(grep -v '^[[:space:]]*#' "$conf_file" | grep -v '^[[:space:]]*$')

	printf '%s\n' "${matched_class:-CODE}"
	return 0
}

#######################################
# Group `CLASS:path` rows into conflict-pattern output lines.
#
# Args:
#   $1 - classified rows, one `CLASS:path` entry per line
#
# Output: multi-line classification string.
#######################################
_emit_grouped_conflict_classifications() {
	local classified_lines="$1"
	local all_classes="ADD_ADD_NEW_FILE DRIZZLE_MIGRATION LOCKFILE I18N_JSON GENERATED CODE"
	local class

	for class in $all_classes; do
		local paths_for_class=""
		while IFS= read -r entry; do
			[[ "$entry" == "${class}:"* ]] || continue
			local p="${entry#*:}"
			paths_for_class="${paths_for_class}${p} "
		done < <(printf '%s\n' "$classified_lines")
		paths_for_class="${paths_for_class% }"
		if [[ -n "$paths_for_class" ]]; then
			printf '%s %s\n' "$class" "$paths_for_class"
		fi
	done
	return 0
}

#######################################
# Classify a list of conflicting file paths against the conflict-patterns.conf
# registry and return a multi-line classification string (t2987).
#
# Each output line has the form:
#   CLASSIFICATION path/to/file1 path/to/file2 ...
#
# Patterns are matched in conf order. CODE is the catch-all fallback.
# Files that match a non-CODE pattern are collected per-classification.
# Unmatched files fall through to the CODE bucket.
#
# add/add detection (t3199): when a repo_path is supplied AND it contains
# an in-progress rebase/merge with `AA` rows in `git status --porcelain`,
# those files are pre-classified as ADD_ADD_NEW_FILE — independent of
# filename glob. add/add conflicts can occur on any path, so glob matching
# is not a reliable signal. The pulse caller (`_dispatch_conflict_fix_worker`)
# does not have a local checkout and passes no repo_path, so its existing
# classification path is preserved. Worker / test contexts that DO have a
# checkout pass repo_path to enable the structural detection.
#
# Args:
#   $1 - newline-separated list of conflicting file paths
#   $2 - (optional) path to conflict-patterns.conf; defaults to the conf
#        in the same directory as this script's parent configs/ dir.
#   $3 - (optional, t3199) path to a git repo with an in-flight rebase/merge.
#        When set and valid, files marked `AA` by `git status --porcelain`
#        are classified as ADD_ADD_NEW_FILE before glob matching.
#
# Output: multi-line classification on stdout (empty if no files given).
#######################################
_classify_conflicts_by_pattern() {
	local file_list="$1"
	local conf_file="${2:-}"
	local repo_path="${3:-}"

	# Locate conf file relative to this script if not supplied.
	if [[ -z "$conf_file" ]]; then
		local script_dir
		script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
		conf_file="${script_dir}/../configs/conflict-patterns.conf"
	fi

	[[ -n "$file_list" ]] || return 0
	[[ -f "$conf_file" ]] || {
		# Fallback: classify everything as CODE if conf is missing.
		printf 'CODE %s\n' "$file_list"
		return 0
	}

	local add_add_set
	add_add_set="$(_conflict_add_add_path_set "$repo_path")"

	local classified_lines=""
	local class

	while IFS= read -r fpath; do
		[[ -n "$fpath" ]] || continue

		if _conflict_path_in_set "$fpath" "$add_add_set"; then
			class="ADD_ADD_NEW_FILE"
		else
			class="$(_conflict_registry_class_for_path "$fpath" "$conf_file")"
		fi
		classified_lines="${classified_lines}${class}:${fpath}"$'\n'
	done < <(printf '%s\n' "$file_list")

	_emit_grouped_conflict_classifications "$classified_lines"
	return 0
}

#######################################
# Emit markdown guidance blocks for each non-CODE conflict pattern (t2987).
#
# Called by _build_conflict_feedback_section after classification to append
# a ### Pattern-Specific Resolution Guidance subsection per detected class.
#
# Args:
#   $1 - classification_output  (multi-line: "CLASS file1 file2 ...")
#   $2 - default_branch         (e.g. "main", "develop")
#   $3 - conf_file              (path to conflict-patterns.conf)
#
# Output: markdown guidance blocks on stdout (nothing if all CODE or empty).
#######################################
_emit_pattern_guidance_blocks() {
	local classification_output="$1"
	local default_branch="$2"
	local conf_file="$3"

	[[ -n "$classification_output" ]] || return 0

	# Check if any non-CODE class is present.
	local has_non_code=0
	while IFS= read -r cls_line; do
		[[ "$cls_line" == CODE\ * ]] || has_non_code=1
	done < <(printf '%s\n' "$classification_output")
	[[ $has_non_code -eq 1 ]] || return 0

	printf '\n### Pattern-Specific Resolution Guidance\n\n'
	printf 'The conflicting files match known patterns with deterministic resolution paths.\n'
	printf 'Follow the per-pattern guidance below before falling back to the generic\n'
	printf 'cherry-pick instructions in the Worker guidance section above.\n\n'

	while IFS= read -r cls_line; do
		[[ -n "$cls_line" ]] || continue
		local class="${cls_line%% *}"
		local files="${cls_line#* }"
		[[ "$class" == "CODE" ]] && continue

		# Look up first matching guidance record in conf for this class.
		local resolution_cmd="" guidance=""
		if [[ -f "$conf_file" ]]; then
			while IFS='|' read -r cr _gr rr guide_raw; do
				# Trim whitespace.
				local cn="${cr#"${cr%%[![:space:]]*}"}"
				cn="${cn%"${cn##*[![:space:]]}"}"
				[[ "$cn" == "$class" ]] || continue
				rr="${rr#"${rr%%[![:space:]]*}"}"; rr="${rr%"${rr##*[![:space:]]}"}"
				guide_raw="${guide_raw#"${guide_raw%%[![:space:]]*}"}"
				guide_raw="${guide_raw%"${guide_raw##*[![:space:]]}"}"
				resolution_cmd="$rr"; guidance="$guide_raw"
				break
			done < <(grep -v '^[[:space:]]*#' "$conf_file" \
				| grep -v '^[[:space:]]*$')
		fi

		printf '#### Pattern: %s\n\n' "$class"
		# shellcheck disable=SC2016  # backticks are literal markdown, not expansion
		printf 'Affected files: `%s`\n\n' "${files// /, }"
		if [[ -n "$guidance" ]]; then
			local expanded="${guidance//\{default_branch\}/${default_branch}}"
			expanded="${expanded//\\n/$'\n'}"
			printf '%s\n\n' "$expanded"
		fi
		if [[ -n "$resolution_cmd" ]]; then
			local rcmd="${resolution_cmd//\{default_branch\}/${default_branch}}"
			# shellcheck disable=SC2016  # backticks are literal markdown, not expansion
			printf 'Quick resolution command: `%s`\n\n' "$rcmd"
		fi
	done < <(printf '%s\n' "$classification_output")
	return 0
}

#######################################
# Classify a list of failing CI check names against ci-failure-patterns.conf
# (t3225). Mirror of _classify_conflicts_by_pattern but operates on check
# names (newline-separated) instead of file paths.
#
# Each output line has the form:
#   CLASSIFICATION name1|name2|...
# (note: separator inside the names list is `|` not space, because check
# names commonly contain spaces e.g. "ShellCheck (ubuntu-latest)").
#
# Patterns are matched in conf order. OTHER is the catch-all fallback.
# Names that match a non-OTHER pattern are collected per-classification.
# Unmatched names fall through to the OTHER bucket.
#
# Args:
#   $1 - newline-separated list of failing CI check names
#   $2 - (optional) path to ci-failure-patterns.conf; defaults to
#        configs/ci-failure-patterns.conf relative to this script.
#
# Output: multi-line classification on stdout (empty if no names given).
#######################################
_classify_ci_failures_by_pattern() {
	local name_list="$1"
	local conf_file="${2:-}"

	if [[ -z "$conf_file" ]]; then
		# Resolve via dirname (no symlink resolution needed for -f / read).
		conf_file="${BASH_SOURCE[0]%/*}/../configs/ci-failure-patterns.conf"
	fi

	[[ -n "$name_list" ]] || return 0
	[[ -f "$conf_file" ]] || {
		printf 'OTHER %s\n' "${name_list//$'\n'/|}"
		return 0
	}

	local classified_lines=
	while IFS= read -r cname; do
		[[ -n "$cname" ]] || continue

		local matched_class=
		while IFS='|' read -r class_raw glob_raw _rest; do
			# Both vars initialised to empty for set -u safety (t2863).
			local class='' glob=''
			class="${class_raw#"${class_raw%%[![:space:]]*}"}"
			class="${class%"${class##*[![:space:]]}"}"
			glob="${glob_raw#"${glob_raw%%[![:space:]]*}"}"
			glob="${glob%"${glob##*[![:space:]]}"}"

			[[ -n "$class" && -n "$glob" ]] || continue
			[[ "$class" == \#* ]] && continue

			# shellcheck disable=SC2254  # dynamic glob is intentional
			case "$cname" in
				$glob)
					matched_class="$class"
					break
					;;
			esac
		done < <(grep -v '^[[:space:]]*#' "$conf_file" | grep -v '^[[:space:]]*$')

		[[ -n "$matched_class" ]] || matched_class="OTHER"
		classified_lines="${classified_lines}${matched_class}::${cname}"$'\n'
	done < <(printf '%s\n' "$name_list")

	# Group by classification, preserving conf-file priority order.
	local all_classes="FORMAT_FAILURE LINT_FAILURE EXTERNAL_STATIC_ANALYSIS TYPECHECK_FAILURE TEST_FAILURE TIMEOUT_NO_OUTPUT OTHER"
	for class in $all_classes; do
		local names_for_class=
		while IFS= read -r entry; do
			[[ "$entry" == "${class}::"* ]] || continue
			local n="${entry#*::}"
			if [[ -z "$names_for_class" ]]; then
				names_for_class="$n"
			else
				names_for_class="${names_for_class}|${n}"
			fi
		done < <(printf '%s\n' "$classified_lines")
		if [[ -n "$names_for_class" ]]; then
			printf '%s %s\n' "$class" "$names_for_class"
		fi
	done
	return 0
}

#######################################
# Emit markdown guidance blocks for each non-OTHER CI failure pattern (t3225).
#
# Mirror of _emit_pattern_guidance_blocks but for CI check names. Reads the
# RESOLUTION_COMMAND and GUIDANCE_TEXT for each detected classification from
# ci-failure-patterns.conf.
#
# Args:
#   $1 - classification_output  (multi-line: "CLASS name1|name2|...")
#   $2 - conf_file              (path to ci-failure-patterns.conf)
#
# Output: markdown guidance blocks on stdout (nothing if all OTHER or empty).
#######################################
_emit_ci_failure_guidance_blocks() {
	local classification_output="$1"
	local conf_file="$2"

	[[ -n "$classification_output" ]] || return 0

	local has_actionable=0
	while IFS= read -r cls_line; do
		[[ -n "$cls_line" ]] || continue
		[[ "$cls_line" == OTHER\ * ]] || has_actionable=1
	done < <(printf '%s\n' "$classification_output")
	[[ $has_actionable -eq 1 ]] || return 0

	printf '\n### Pattern-Specific Resolution Guidance\n\n'
	printf 'The failing checks match known patterns with deterministic resolution paths.\n'
	printf 'Try the auto-fix sequence(s) below FIRST, before falling back to the\n'
	printf 'generic worker guidance further down.\n\n'

	while IFS= read -r cls_line; do
		[[ -n "$cls_line" ]] || continue
		local class="${cls_line%% *}"
		local names="${cls_line#* }"
		[[ "$class" == "OTHER" ]] && continue

		local resolution_cmd="" guidance=""
		local fallback_resolution_cmd="" fallback_guidance="" cr="" gr="" rr="" guide_raw=""
		if [[ -f "$conf_file" ]]; then
			while IFS='|' read -r cr gr rr guide_raw; do
				local cn="${cr#"${cr%%[![:space:]]*}"}"
				cn="${cn%"${cn##*[![:space:]]}"}"
				[[ "$cn" == "$class" ]] || continue
				local glob="${gr#"${gr%%[![:space:]]*}"}"
				glob="${glob%"${glob##*[![:space:]]}"}"
				rr="${rr#"${rr%%[![:space:]]*}"}"; rr="${rr%"${rr##*[![:space:]]}"}"
				guide_raw="${guide_raw#"${guide_raw%%[![:space:]]*}"}"
				guide_raw="${guide_raw%"${guide_raw##*[![:space:]]}"}"
				if [[ -z "$fallback_resolution_cmd" && -z "$fallback_guidance" ]]; then
					fallback_resolution_cmd="$rr"; fallback_guidance="$guide_raw"
				fi

				local name=""
				while IFS= read -r name; do
					[[ -n "$name" ]] || continue
					# shellcheck disable=SC2254  # dynamic glob is intentional
					case "$name" in
						$glob)
							resolution_cmd="$rr"; guidance="$guide_raw"
							break 2
							;;
					esac
				done < <(printf '%s\n' "${names//|/$'\n'}")
			done < <(grep -v '^[[:space:]]*#' "$conf_file" \
				| grep -v '^[[:space:]]*$')
		fi
		if [[ -z "$resolution_cmd" && -n "$fallback_resolution_cmd" ]]; then
			resolution_cmd="$fallback_resolution_cmd"; guidance="$fallback_guidance"
		fi

		printf '#### Pattern: %s\n\n' "$class"
		# shellcheck disable=SC2016  # backticks are literal markdown
		printf 'Affected checks: `%s`\n\n' "${names//|/, }"
		if [[ -n "$guidance" ]]; then
			local expanded="${guidance//\\n/$'\n'}"
			printf '%s\n\n' "$expanded"
		fi
		if [[ -n "$resolution_cmd" ]]; then
			# shellcheck disable=SC2016  # backticks are literal markdown
			printf 'Quick resolution command: `%s`\n\n' "$resolution_cmd"
		fi
	done < <(printf '%s\n' "$classification_output")
	return 0
}

#######################################
# Build the conflict-feedback Markdown section for a closed-conflict PR.
#
# Produces the "## Merge Conflict Feedback" block appended to the linked
# issue body. Leads with cherry-pick-first guidance (t2426) — the prior
# worker's commit is usually correct-but-stale, so cherry-picking onto a
# fresh branch off current default branch is ~10x cheaper than rewriting.
#
# Scope-leak heuristic (t2802): if the prior PR touched more files than a
# focused fix should, that's a signal the BRANCH BASE was wrong, not that
# the semantic conflict is real. Rebuilding from the issue body is then
# cheaper than cherry-picking a scope-leaked branch. Canonical failure:
# example-repo#2716 / PR #2733 (100 files for a 2-line fix). Successive
# workers burned opus tokens trying to cherry-pick the monster.
#
# Extracted from _dispatch_conflict_fix_worker to keep that function under
# the 100-line threshold (function-complexity gate).
#
# Args: $1=pr_number, $2=pr_title, $3=pr_files, $4=pr_head_sha,
#       $5=default_branch (e.g. "main", "develop"),
#       $6=pr_file_count (integer, may be empty)
# Stdout: the rendered section
#######################################
_build_conflict_feedback_section() {
	local pr_number="$1"
	local pr_title="$2"
	local pr_files="$3"
	local pr_head_sha="$4"
	local default_branch="${5:-main}"
	local pr_file_count="${6:-}"

	# Locate the conflict-patterns.conf registry (t2987).
	local script_dir
	script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
	local conf_file="${script_dir}/../configs/conflict-patterns.conf"

	# Classify conflicting files for pattern-aware guidance (t2987).
	local classification_output=""
	if [[ -n "$pr_files" ]]; then
		classification_output=$(_classify_conflicts_by_pattern "$pr_files" "$conf_file")
	fi

	# Scope-leak detection (t2802). If prior PR touched >20 files, the
	# base was probably wrong (canonical HEAD stale). Cherry-picking a
	# scope-leaked branch is expensive and usually fails the same way
	# the first attempt did. Surface the signal upfront so the worker
	# rebuilds from the issue body instead of chasing a ghost diff.
	#
	# Build as plain quoted string (not heredoc-in-$()) so bash 3.2 accepts it.
	local scope_leak_warning=""
	if [[ -n "$pr_file_count" ]] && [[ "$pr_file_count" =~ ^[0-9]+$ ]] && ((pr_file_count > 20)); then
		scope_leak_warning="> ⚠ **Scope-leak signal**: the prior PR touched **${pr_file_count} files**. For most
> conflict-feedback loops the touch-count should be 1-5. A high count usually means
> the prior worker's branch was created off a stale canonical HEAD (not \`origin/${default_branch}\`),
> so the diff = \"everything ${default_branch} has that the stale base doesn't\" + the actual fix.
>
> **If the file list below looks unrelated to the original issue scope, skip the
> cherry-pick entirely** and rebuild from the issue body onto a fresh branch explicitly
> based on \`origin/${default_branch}\`. Cherry-picking a scope-leaked branch will fail
> the same way — that is why the prior attempt was closed.
>
> Framework fix in-flight: t2802 makes \`worktree-helper.sh add\` base new branches
> on \`origin/<default>\` explicitly instead of inheriting canonical HEAD."
	fi

	# Build scope-warning block separately to avoid interpolating empty lines.
	local scope_block=""
	if [[ -n "$scope_leak_warning" ]]; then
		scope_block=$'\n'"${scope_leak_warning}"$'\n'
	fi

	cat <<-EOF
		## Merge Conflict Feedback (from PR #${pr_number})

		The previous worker's PR #${pr_number} (\`${pr_title}\`) developed merge conflicts with
		\`${default_branch}\` that could not be resolved by \`gh pr update-branch\` (server-side fast-forward).
		The conflicts are semantic — the same files were modified on both branches${pr_file_count:+ (${pr_file_count} files touched)}.${scope_block}

		### Files in the conflicting PR

		\`\`\`
		${pr_files}
		\`\`\`

		### Worker guidance

		The prior PR's head commit is \`${pr_head_sha:-<lookup via gh pr view ${pr_number} --json headRefOid>}\`. Choose the cheapest path that works:

		1. **Cherry-pick onto a fresh branch off current \`origin/${default_branch}\`** (~10x cheaper than rewriting, works when the prior implementation was correct-but-stale):

		   \`\`\`bash
		   git fetch origin pull/${pr_number}/head:recovered-${pr_number}
		   # Explicit base on origin/${default_branch} — NOT canonical HEAD (t2802).
		   git worktree add -b fresh-branch ../fresh-worktree origin/${default_branch}
		   cd ../fresh-worktree
		   git cherry-pick ${pr_head_sha:-<head-sha>}
		   # run tests — if clean, proceed to PR
		   \`\`\`

		2. **If cherry-pick surfaces conflicts**, resolve them. The conflict surface IS the semantic overlap between the two branches — resolve those specific hunks rather than rewriting untouched logic.

		3. **If the scope-leak warning above fired** (prior PR >20 files but the issue describes a focused fix), **skip cherry-pick entirely** and rebuild from scratch using the issue body as the spec. Do NOT try to cherry-pick-then-drop-files — too error-prone. A clean rewrite from the 2-line spec is cheaper than surgery on a 100-file branch.

		4. **Only rewrite from scratch (scope-OK case)** if the prior approach was rejected in review. Check PR #${pr_number}'s review comments for \`CHANGES_REQUESTED\` or rejection keywords before assuming the approach was wrong.

		Do NOT reuse the old PR's branch directly — always cherry-pick onto a fresh branch off current \`origin/${default_branch}\`.

		_Routed by deterministic merge pass (pulse-merge.sh)._
	EOF

	# Append pattern-specific guidance block for non-CODE patterns (t2987).
	_emit_pattern_guidance_blocks "$classification_output" "$default_branch" "$conf_file"
	return 0
}

#######################################
# Route merge conflict context from a worker PR to its linked issue, close
# the PR, and set the issue to status:available for re-dispatch.
#
# Called when `gh pr update-branch` fails (true semantic conflict) on a
# worker PR. The next worker gets the conflict context and the list of
# conflicting files in its prompt.
#
# Args: $1=pr_number, $2=repo_slug, $3=linked_issue, $4=pr_title
#######################################
_dispatch_conflict_fix_worker() {
	local pr_number="$1"
	local repo_slug="$2"
	local linked_issue="$3"
	local pr_title="$4"

	[[ "$pr_number" =~ ^[0-9]+$ ]] || return 0
	[[ -n "$repo_slug" ]] || return 0
	[[ "$linked_issue" =~ ^[0-9]+$ ]] || return 0

	# Create labels (idempotent, --force)
	gh label create "conflict-feedback-routed" --repo "$repo_slug" --color "D4C5F9" \
		--description "Worker PR with merge conflicts routed to linked issue for re-dispatch" \
		--force >/dev/null 2>&1 || true
	gh label create "source:conflict-feedback" --repo "$repo_slug" --color "E6D8FA" \
		--description "Issue carries conflict context routed from a closed worker PR" \
		--force >/dev/null 2>&1 || true

	# Get the list of files changed in the PR (these are the conflict candidates)
	local pr_files
	pr_files=$(gh pr view "$pr_number" --repo "$repo_slug" \
		--json files --jq '[.files[].path] | join("\n")' 2>/dev/null) || pr_files="(could not fetch)"

	# File count for scope-leak heuristic (t2802). Rely on the already-fetched
	# file list rather than a second API call — a line count of the joined
	# output matches the files array length when pr_files fetched cleanly.
	local pr_file_count=""
	if [[ -n "$pr_files" ]] && [[ "$pr_files" != "(could not fetch)" ]]; then
		pr_file_count=$(printf '%s\n' "$pr_files" | grep -c '^.' || true)
	fi

	# Get the closed PR's head commit SHA (t2426) — reachable for >=30 days after close
	# and lets the next worker cherry-pick instead of rewriting from scratch.
	local pr_head_sha
	pr_head_sha=$(gh pr view "$pr_number" --repo "$repo_slug" \
		--json headRefOid --jq '.headRefOid' 2>/dev/null) || pr_head_sha=""

	# Default branch for the repo. Use gh for the authoritative answer (the
	# pulse may run from a repo path that differs from repo_slug). Fall back
	# to "main" if detection fails — matches pre-t2802 behaviour.
	local default_branch
	default_branch=$(gh repo view "$repo_slug" \
		--json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null) || default_branch=""
	[[ -n "$default_branch" ]] || default_branch="main"

	local feedback_section
	feedback_section=$(_build_conflict_feedback_section \
		"$pr_number" "$pr_title" "$pr_files" "$pr_head_sha" \
		"$default_branch" "$pr_file_count")

	# Append to issue body (marker-guarded, t2383 fail-safe)
	local marker="<!-- conflict-feedback:PR${pr_number} -->"
	_append_feedback_to_issue "$linked_issue" "$repo_slug" "$marker" \
		"$feedback_section" "_dispatch_conflict_fix_worker" || return 0

	# Transition issue to available for re-dispatch
	_transition_issue_for_redispatch "$linked_issue" "$repo_slug" "source:conflict-feedback"

	# Close the PR with conflict context
	_close_and_label_feedback_pr "$pr_number" "$repo_slug" \
		"## Merge conflict feedback routed to issue #${linked_issue}

This worker PR had semantic merge conflicts with \`${default_branch}\` that \`update-branch\` could not resolve. The conflict context and file list have been appended to the linked issue body so the next worker can re-implement on top of current \`${default_branch}\`.

_Closed by deterministic merge pass (pulse-merge.sh)._" \
		"conflict-feedback-routed"

	echo "[pulse-wrapper] _dispatch_conflict_fix_worker: routed conflict feedback from PR #${pr_number} to issue #${linked_issue} in ${repo_slug}" >>"$LOGFILE"
	return 0
}

#######################################
# Route review feedback from a stuck worker PR to its linked issue and
# close the PR so the dispatch queue can re-pick the task (t2093).
#
# Called by `_check_pr_merge_gates` when `reviewDecision=CHANGES_REQUESTED`
# on a worker-authored PR with a linked issue. Before this helper existed,
# such PRs accumulated indefinitely: the merge pass skipped them (correctly,
# since they can't pass the review gate as-is), but nothing dispatched a
# fresh worker to address the feedback. The PR author is the headless
# worker account, so no human was notified; the review-followup pipeline
# only fires on *merged* PRs; and the dispatch-dedup guard treated the
# open PR as an active claim on the linked issue.
#
# This function closes that loop:
#   1. Fetches bot reviews + inline comments from the stuck PR.
#   2. Appends a "Review Feedback" section to the linked issue body
#      (marker-guarded so re-runs are idempotent).
#   3. Transitions the linked issue to `status:available` and tags it
#      `source:review-feedback` so the next dispatch cycle picks it up
#      with the feedback in the prompt.
#   4. Closes the stuck PR with an explanatory comment and tags it
#      `review-routed-to-issue` as a belt-and-suspenders idempotency flag.
#
# Interactive PRs and external-contributor PRs are filtered out by the
# caller (`_check_pr_merge_gates`) — they have their own review flows.
#
# Fail-open: any API failure is logged and swallowed. The merge pass must
# continue processing other PRs.
#
# Reference patterns:
#   - `quality-feedback-helper.sh` — bot review comment extraction
#   - `_close_conflicting_pr`      — close-with-comment boilerplate
#   - `draft-response-helper.sh`   — issue body append pattern
#
# Args:
#   $1 - pr_number
#   $2 - repo_slug  (owner/repo)
#   $3 - linked_issue  (the issue the PR resolves/fixes/closes)
#######################################
_dispatch_pr_fix_worker() {
	local pr_number="$1"
	local repo_slug="$2"
	local linked_issue="$3"

	[[ "$pr_number" =~ ^[0-9]+$ ]] || return 0
	[[ -n "$repo_slug" ]] || return 0
	[[ "$linked_issue" =~ ^[0-9]+$ ]] || return 0

	# Ensure the idempotency + origin labels exist on the repo (idempotent,
	# --force, swallowed failures). quality-feedback-helper.sh also creates
	# source:review-feedback — redundant creation is harmless.
	gh label create "review-routed-to-issue" --repo "$repo_slug" --color "D93F0B" \
		--description "Worker PR with CHANGES_REQUESTED routed to linked issue for re-dispatch (t2093)" \
		--force >/dev/null 2>&1 || true
	gh label create "source:review-feedback" --repo "$repo_slug" --color "C2E0C6" \
		--description "Issue carries review feedback routed from a closed worker PR" \
		--force >/dev/null 2>&1 || true

	# --- Fetch bot/human reviews (substantive: CHANGES_REQUESTED or long body) ---
	local reviews_json
	reviews_json=$(gh api "repos/${repo_slug}/pulls/${pr_number}/reviews" \
		--paginate \
		--jq '[.[] | select(.state == "CHANGES_REQUESTED" or ((.body // "") | length) > 30)
			| {author: (.user.login // "unknown"), state: .state,
			   body: (.body // ""), url: (.html_url // "")}]' \
		2>/dev/null) || reviews_json="[]"
	[[ -n "$reviews_json" ]] || reviews_json="[]"

	# --- Fetch inline review comments (file:line citations) ---
	local inline_json
	inline_json=$(gh api "repos/${repo_slug}/pulls/${pr_number}/comments" \
		--paginate \
		--jq '[.[] | {author: (.user.login // "unknown"),
			path: (.path // ""),
			line: (.line // .original_line // 0),
			body: (.body // ""), url: (.html_url // "")}]' \
		2>/dev/null) || inline_json="[]"
	[[ -n "$inline_json" ]] || inline_json="[]"

	# --- Build the Review Feedback markdown section ---
	local feedback_section
	feedback_section=$(_build_review_feedback_section \
		"$pr_number" "$repo_slug" "$reviews_json" "$inline_json") || feedback_section=""
	if [[ -z "$feedback_section" ]]; then
		echo "[pulse-wrapper] _dispatch_pr_fix_worker: PR #${pr_number} in ${repo_slug} has CHANGES_REQUESTED but no substantive review content — leaving PR open without routing (t2093)" >>"$LOGFILE"
		return 0
	fi

	# --- Append to linked issue body (marker-guarded, t2383 fail-safe) ---
	local marker="<!-- t2093:review-feedback:PR${pr_number} -->"
	_append_feedback_to_issue "$linked_issue" "$repo_slug" "$marker" \
		"$feedback_section" "_dispatch_pr_fix_worker" || return 0

	# --- Transition issue status to available for re-dispatch ---
	_transition_issue_for_redispatch "$linked_issue" "$repo_slug" "source:review-feedback"

	# --- Close the stuck PR with explanatory comment ---
	local close_comment
	close_comment="## Review feedback routed to linked issue #${linked_issue} (t2093)

This worker-authored PR had \`reviewDecision=CHANGES_REQUESTED\`. Rather than let it sit
indefinitely (no human owns worker PRs and the dispatch-dedup guard treats an open worker
PR as an active claim), the deterministic merge pass has:

1. Extracted the review feedback (top-level reviews + file:line inline comments) and
   appended it to the linked issue body as a \"Review Feedback\" section.
2. Closed this PR so the dispatch queue can re-pick the linked issue.
3. Transitioned issue #${linked_issue} to \`status:available\` and tagged it
   \`source:review-feedback\` so the next pulse cycle dispatches a fresh worker with
   the feedback in its prompt.

The next worker will see the updated issue body, address the review findings, and
open a fresh PR against issue #${linked_issue}.

_Closed by deterministic merge pass (pulse-merge.sh, t2093)._"

	# Mark the PR as routed so any racing merge-pass re-read (via cached
	# listing) skips re-processing. This is belt-and-suspenders — closed
	# PRs are already excluded from the merge cycle's open-PR query.
	_close_and_label_feedback_pr "$pr_number" "$repo_slug" \
		"$close_comment" "review-routed-to-issue"

	echo "[pulse-wrapper] _dispatch_pr_fix_worker: routed review feedback from PR #${pr_number} to issue #${linked_issue} in ${repo_slug} (t2093)" >>"$LOGFILE"
	return 0
}
