#!/usr/bin/env bash
# failure — preserve the attempt's commits as a tag before settlement.
#
# ENVIRONMENT:
#   TICKET_ID, TICKET_REF, TICKET_FLOW   as before received them
#   ATTEMPT_DIR      the worktree before made; may already be gone if the
#                    fault struck early. You are started inside it when it
#                    still exists.
#   RUN_ID, RUN_CAUSE, RUN_EXIT, BOT_CMD the run's outcome and its key
#   SETTLEMENT       the factory's disposition for this attempt
#   SDLC_REPO        baggage from before: the repository's checkout
#   SDLC_BRANCH      baggage from before: ticket/<TICKET_ID>
#
# THE RULE: nothing is deleted that is not first tagged.
#   If the branch adds commits to main, they are tagged
#   attempt/<TICKET_ID>-<YYYYMMDD>-<n> — the first free n for today — before
#   the branch or the worktree is touched. If the tag cannot be written, a
#   teardown does not happen and this script refuses (exit 2). A preserve-whole
#   settlement reports a tag failure but keeps the branch and worktree. If the
#   branch adds nothing, there is nothing to preserve and teardown is safe.
#
# HONORING SETTLEMENT: when SETTLEMENT says the item is out of attempts
#   (failed, exhausted, "attempts spent", out-of-attempts) or says leave/left,
#   the attempt is preserved WHOLE — branch, worktree and all — after its
#   commits are tagged, because a human is about to stand in it. A blocked
#   attempt also preserves whole, but runs its optional $TREE/sdlc/scripts/clean
#   after tagging to shed rebuildable output. An absent clean is skipped; an
#   existing non-executable clean faults without settling; a failed clean is
#   reported but does not cost the preservation. A ready fault on the owned
#   ticket branch keeps that branch and worktree for the retry. Every other
#   retry settlement, including an empty or unrecognized one, tears down. A
#   rejected run therefore gets a clean tree. A successful run whose own ticket
#   branch could not land keeps its tagged branch and owned worktree for the
#   next attempt to continue.
#
# STDOUT:  one line per action taken or preserved thing.
# EXIT:    0 handled — a retry may proceed per SETTLEMENT
#          2 refuse — the evidence could not be preserved, or the worktree is
#            not ours to remove; a human reads the reason on stderr
#          any other nonzero would be recorded as a fault; either way the
#            settlement the factory already decided does not change
if ((BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 2))); then
	printf 'Bash %s is unsupported; Bash 3.2 or newer is required\n' "$BASH_VERSION" >&2
	exit 2
fi

set -euo pipefail

git_with_optional_timeout() {
	if command -v timeout >/dev/null 2>&1; then
		timeout -k 10 60 git "$@"
	else
		git "$@"
	fi
}

: "${TICKET_ID:?failure: TICKET_ID is required}"
case "$TICKET_ID" in
[0-9][0-9][0-9][0-9]) ;;
*)
	echo "failure: TICKET_ID must be exactly four digits" >&2
	exit 2
	;;
esac

WITHDRAWN=0
case "$(printf '%s' "${SETTLEMENT-}" | tr '[:upper:]' '[:lower:]')" in
withdrawn) WITHDRAWN=1 ;;
esac

BRANCH=${SDLC_BRANCH:-ticket/$TICKET_ID}
if [ "$WITHDRAWN" -eq 1 ]; then
	# Withdrawal has no attempt baggage. Its branch name is the exact ticket
	# ref, never a caller-provided approximation.
	BRANCH="ticket/$TICKET_ID"
fi
REF="refs/heads/$BRANCH"
TREE=${ATTEMPT_DIR:-}
BASELINE=
if [ -n "$TREE" ]; then
	BASELINE="$TREE.baseline"
fi

# Preserve whole settlements still tag their commits before they stop.
BLOCKED=0
PRESERVE=0
case "$(printf '%s' "${SETTLEMENT-}" | tr '[:upper:]' '[:lower:]')" in
*leave* | *left* | *fail* | *exhaust* | *"out of attempts"* | *out-of-attempts* | *"attempts spent"*)
	PRESERVE=1
	;;
blocked)
	BLOCKED=1
	PRESERVE=1
	;;
esac

resolve_repo() {
	local from=$1 common
	common=$(git -C "$from" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1
	(CDPATH='' cd -- "$common/.." && pwd -P)
}

if [ "$WITHDRAWN" -eq 1 ]; then
	if ! REPO=$(git -C "$(pwd -P)" rev-parse --show-toplevel 2>/dev/null) ||
		! REPO=$(CDPATH='' cd -- "$REPO" && pwd -P); then
		echo "failure: cannot tell which registered checkout owns $BRANCH" >&2
		exit 2
	fi
elif [ -n "${SDLC_REPO-}" ]; then
	REPO=$SDLC_REPO
elif [ -n "$TREE" ] && [ -d "$TREE" ] && REPO=$(resolve_repo "$TREE"); then
	:
elif REPO=$(resolve_repo "$(pwd -P)"); then
	:
else
	echo "failure: cannot tell which checkout owns $BRANCH — no SDLC_REPO, no worktree, and pwd is not a checkout" >&2
	exit 2
fi
if ! git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1; then
	echo "failure: $REPO is not a Git repository" >&2
	exit 2
fi
if ! common=$(git -C "$REPO" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) ||
	! REPO_COMMON=$(CDPATH='' cd -- "$common" && pwd -P); then
	echo "failure: cannot determine the Git common directory for $REPO" >&2
	exit 2
fi

candidate_owned() {
	local candidate=$1 top candidate_path top_path candidate_common
	top=$(git -C "$candidate" rev-parse --show-toplevel 2>/dev/null) || return 1
	candidate_path=$(CDPATH='' cd -- "$candidate" && pwd -P) || return 1
	top_path=$(CDPATH='' cd -- "$top" && pwd -P) || return 1
	[ "$candidate_path" = "$top_path" ] || return 1
	candidate_common=$(git -C "$candidate" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1
	candidate_common=$(CDPATH='' cd -- "$candidate_common" && pwd -P) || return 1
	[ "$candidate_common" = "$REPO_COMMON" ]
}

lexical_path() {
	local path=$1 part
	local -a parts=() source=()
	case "$path" in
	/*) ;;
	*) return 1 ;;
	esac
	IFS=/ read -r -a source <<< "$path"
	for part in "${source[@]}"; do
		case "$part" in
		'' | .) ;;
		..)
			if [ "${#parts[@]}" -gt 0 ]; then
				unset 'parts[${#parts[@]} - 1]'
			fi
			;;
		*) parts+=("$part") ;;
		esac
	done
	local IFS=/
	printf '/%s\n' "${parts[*]}"
}

orphan_owned() {
	local candidate=$1 candidate_path pointer normalized extra
	[ -d "$candidate" ] || return 1
	[ -f "$candidate/.git" ] && [ ! -L "$candidate/.git" ] || return 1
	{
		IFS= read -r pointer || [ -n "$pointer" ] || return 1
		if IFS= read -r extra || [ -n "$extra" ]; then
			return 1
		fi
	} <"$candidate/.git" || return 1
	case "$pointer" in
	"gitdir: "?*) pointer=${pointer#gitdir: } ;;
	*) return 1 ;;
	esac
	candidate_path=$(CDPATH='' cd -- "$candidate" && pwd -P) || return 1
	case "$pointer" in
	/*) normalized=$(lexical_path "$pointer") ;;
	*) normalized=$(lexical_path "$candidate_path/$pointer") ;;
	esac
	case "$normalized" in
	"$REPO_COMMON"/*) return 0 ;;
	*) return 1 ;;
	esac
}

registered_worktree() {
	local candidate=$1 candidate_path listing line registered
	candidate_path=$(lexical_path "$candidate") || return 1
	listing=$(mktemp) || return 1
	if ! git -C "$REPO" worktree list --porcelain -z >"$listing"; then
		rm -f -- "$listing"
		return 1
	fi
	while IFS= read -r -d '' line; do
		case "$line" in
		"worktree "*)
			registered=$(lexical_path "${line#worktree }") || continue
			if [ "$registered" = "$candidate_path" ]; then
				rm -f -- "$listing"
				return 0
			fi
			;;
		esac
	done <"$listing"
	rm -f -- "$listing"
	return 1
}

owned_worktree() {
	candidate_owned "$1" || orphan_owned "$1" || registered_worktree "$1"
}

withdrawn_registered_worktree() {
	local listing line current=
	listing=$(mktemp) || return 1
	if ! git -C "$REPO" worktree list --porcelain -z >"$listing"; then
		rm -f -- "$listing"
		return 1
	fi
	while IFS= read -r -d '' line; do
		case "$line" in
		"worktree "*) current=${line#worktree } ;;
		"branch $REF")
			[ -n "$current" ] || {
				rm -f -- "$listing"
				return 1
			}
			REGISTERED+=("$current")
			;;
		esac
	done <"$listing"
	rm -f -- "$listing"
}

# Withdrawal starts in the registered project checkout, not an attempt. Git's
# exact branch registrations are the only authority for choosing a tree.
if [ "$WITHDRAWN" -eq 1 ]; then
	REGISTERED=()
	if ! withdrawn_registered_worktree; then
		echo "failure: could not read worktree registrations for $BRANCH" >&2
		exit 2
	fi
	if [ "${#REGISTERED[@]}" -gt 1 ]; then
		echo "failure: refusing withdrawal: multiple worktrees are registered for $BRANCH" >&2
		exit 2
	fi
	TREE=
	BASELINE=
	if [ "${#REGISTERED[@]}" -eq 1 ]; then
		TREE=${REGISTERED[0]}
		if [ ! -d "$TREE" ] || [ -L "$TREE" ] || ! candidate_owned "$TREE"; then
			echo "failure: refusing withdrawal: registered worktree $TREE is not safely owned by $REPO" >&2
			exit 2
		fi
		BASELINE="$TREE.baseline"
		if [ -e "$BASELINE" ] || [ -L "$BASELINE" ]; then
			if [ ! -d "$BASELINE" ] || [ -L "$BASELINE" ] || ! owned_worktree "$BASELINE"; then
				echo "failure: refusing withdrawal: baseline $BASELINE is not safely owned by $REPO" >&2
				exit 2
			fi
		fi
		if [ -z "${BOT_CMD-}" ] || ! command -v "$BOT_CMD" >/dev/null 2>&1; then
			echo "failure: could not establish liveness for worktree $TREE: BOT_CMD is unavailable" >&2
			exit 2
		fi
		if "$BOT_CMD" busy "$TREE"; then
			echo "failure: refusing withdrawal: a live run holds worktree $TREE" >&2
			exit 2
		else
			liveness=$?
		fi
		if [ "$liveness" -ne 1 ]; then
			echo "failure: could not establish liveness for worktree $TREE" >&2
			exit 2
		fi
	fi
fi

# Refuse before touching anything: a worktree that is not ours is not ours to
# remove, and a half teardown is worse than none.
if [ -n "$TREE" ] && [ -e "$TREE" ] && ! owned_worktree "$TREE"; then
	echo "failure: refusing to remove $TREE: it is not a worktree owned by $REPO" >&2
	exit 2
fi
if [ -n "$BASELINE" ] && [ -e "$BASELINE" ] && ! owned_worktree "$BASELINE"; then
	echo "failure: refusing to remove $BASELINE: it is not a worktree owned by $REPO" >&2
	exit 2
fi

if [ "$WITHDRAWN" -eq 1 ]; then
	# show-ref --verify and the ref-name comparison below avoid prefix matches.
	LOCAL=$(git -C "$REPO" show-ref --verify --hash "$REF" 2>/dev/null || :)
else
	LOCAL=$(git -C "$REPO" for-each-ref --format='%(objectname)' "$REF")
fi
if ! git_with_optional_timeout -C "$REPO" fetch --quiet --no-tags origin; then
	echo "failure: git fetch origin failed — refusing to tear down without a current view of origin" >&2
	exit 2
fi
if [ "$WITHDRAWN" -eq 1 ]; then
	REMOTE=$(git -C "$REPO" ls-remote --heads origin "$REF" | awk -v ref="$REF" '$2 == ref { print $1; exit }')
else
	REMOTE=$(git -C "$REPO" ls-remote --heads origin "$REF" | awk 'NR == 1 { print $1 }')
fi

if [ -z "$LOCAL" ] && [ -z "$REMOTE" ] && [ "$WITHDRAWN" -ne 1 ]; then
	echo "$BRANCH has no local or origin tip; nothing to preserve"
fi

# 1. Preserve. A tip that adds nothing to origin/main is already in main or
# empty, and needs no tag.
unpreserved() {
	local tip=$1
	[ -n "$tip" ] || return 1
	git -C "$REPO" cat-file -e "$tip^{commit}" 2>/dev/null || return 1
	[ "$(git -C "$REPO" rev-list --count "origin/main..$tip" 2>/dev/null || echo 0)" -gt 0 ]
}

if [ -n "$REMOTE" ] && ! git -C "$REPO" cat-file -e "$REMOTE^{commit}" 2>/dev/null; then
	if ! git_with_optional_timeout -C "$REPO" fetch --quiet --no-tags origin "$REMOTE"; then
		echo "failure: could not fetch the origin tip of $BRANCH; refusing to delete it unpreserved" >&2
		exit 2
	fi
fi

TAGS=()
if [ -n "$REMOTE" ] && [ -n "$LOCAL" ] && [ "$REMOTE" != "$LOCAL" ]; then
	# Two divergent tips are two pieces of evidence; both get preserved.
	if unpreserved "$REMOTE"; then TAGS+=("origin:$REMOTE"); fi
	if unpreserved "$LOCAL"; then TAGS+=("local:$LOCAL"); fi
else
	tip=${LOCAL:-$REMOTE}
	if unpreserved "$tip"; then TAGS+=("only:$tip"); fi
fi

if [ "${#TAGS[@]}" -gt 0 ]; then
	day=$(date -u +%Y%m%d)
	slot=1
	# A descendant name also makes the candidate uncreatable, so look for any.
	while [ -n "$(git -C "$REPO" for-each-ref --format='%(refname)' "refs/tags/attempt/$TICKET_ID-$day-$slot*")" ]; do
		slot=$((slot + 1))
	done
	base="attempt/$TICKET_ID-$day-$slot"
	for entry in "${TAGS[@]}"; do
		kind=${entry%%:*}
		hash=${entry#*:}
		case "$kind" in
		local) tag="$base-local" ;;
		*) tag="$base" ;;
		esac
		if ! git -C "$REPO" tag "$tag" "$hash" 2>/dev/null; then
			if [ "$PRESERVE" -eq 1 ]; then
				echo "failure: could not tag $hash as $tag; preserving untagged work" >&2
			else
				echo "failure: could not tag $hash as $tag; refusing to tear down unpreserved work" >&2
				exit 2
			fi
		elif [ "$WITHDRAWN" -ne 1 ]; then
			echo "tagged $tag $hash"
		fi
	done
fi

if [ "$PRESERVE" -eq 1 ]; then
	if [ "$BLOCKED" -eq 1 ]; then
		CLEAN="$TREE/sdlc/scripts/clean"
		if [ -n "$TREE" ] && [ -e "$CLEAN" ]; then
			if [ ! -x "$CLEAN" ]; then
				echo "failure: clean must be executable: $CLEAN" >&2
				exit 2
			fi
			if (cd -- "$TREE" && ./sdlc/scripts/clean); then
				:
			else
				echo "clean failed: preserving $BRANCH and $TREE"
			fi
		fi
	fi
	echo "settlement \"${SETTLEMENT-}\": preserving $BRANCH and ${TREE:-the worktree} untouched"
	exit 0
fi

# A retryable fault continues from its own evidence. The ownership proof above
# protects an existing worktree before this retention.
if [ "${RUN_CAUSE-}" = "fault" ] &&
	[ "$(printf '%s' "${SETTLEMENT-}" | tr '[:upper:]' '[:lower:]')" = "ready" ] &&
	[ "$WITHDRAWN" -eq 0 ] && [ "$BRANCH" = "ticket/$TICKET_ID" ]; then
	if [ -n "$TREE" ] && [ -e "$TREE" ]; then
		echo "ready fault: retaining tagged branch $BRANCH and owned worktree $TREE"
	elif [ -n "$LOCAL" ] || [ -n "$REMOTE" ]; then
		echo "ready fault: retaining tagged branch $BRANCH"
	else
		echo "ready fault: no owned branch or worktree to retain"
	fi
	exit 0
fi

# A completed run that could not land continues from its own evidence. The
# ownership proof above protects an existing worktree before this retention.
if [ "${RUN_CAUSE-}" = "success" ] && [ "$BRANCH" = "ticket/$TICKET_ID" ]; then
	if [ -n "$TREE" ] && [ -e "$TREE" ]; then
		echo "landing fault: retaining tagged branch $BRANCH and owned worktree $TREE"
	else
		echo "landing fault: retaining tagged branch $BRANCH"
	fi
	exit 0
fi

# 2. Teardown. Only now, and only what the tags cover.
failures=
# Report what happened, not what was attempted: the "did" line is printed
# only when the action returned success.
cleanup() {
	local name=$1 did=$2
	shift 2
	if "$@"; then
		if [ "$WITHDRAWN" -ne 1 ]; then
			[ -z "$did" ] || echo "$did"
		fi
	else
		failures="${failures}${failures:+, }$name"
		return 1
	fi
}

remove_tree() {
	local candidate=$1
	if git -C "$REPO" worktree remove --force "$candidate" >/dev/null 2>&1; then
		return 0
	fi
	[ -e "$candidate" ] || return 0
	if ! owned_worktree "$candidate"; then
		echo "failure: refusing to remove $candidate: it is not a worktree owned by $REPO" >&2
		return 1
	fi
	rm -rf "$candidate"
}
remove_baseline() {
	if ! owned_worktree "$BASELINE"; then
		echo "failure: refusing to remove $BASELINE: it is not a worktree owned by $REPO" >&2
		return 1
	fi
	remove_tree "$BASELINE"
}
prune_worktrees() {
	git -C "$REPO" worktree prune >/dev/null 2>&1
}
remove_local_branch() {
	[ -n "$LOCAL" ] || return 0
	git -C "$REPO" update-ref -d "$REF" "$LOCAL"
}
remove_origin_branch() {
	[ -n "$REMOTE" ] || return 0
	git -C "$REPO" push --quiet "--force-with-lease=$REF:$REMOTE" origin ":$REF"
}

tree_removed=1
if [ -n "$TREE" ] && [ -e "$TREE" ]; then
	cleanup "worktree removal $TREE" "removed worktree $TREE" remove_tree "$TREE" || tree_removed=0
fi
if [ -n "$BASELINE" ] && [ -e "$BASELINE" ]; then
	cleanup "baseline worktree removal $BASELINE" "removed baseline worktree $BASELINE" remove_baseline || :
fi
cleanup "worktree prune" "" prune_worktrees || :
if [ "$tree_removed" -eq 1 ] && [ -n "$LOCAL" ]; then
	REGISTERED=()
	if ! withdrawn_registered_worktree; then
		failures="${failures}${failures:+, }worktree registration read for $BRANCH"
	elif [ "${#REGISTERED[@]}" -gt 0 ]; then
		failures="${failures}${failures:+, }registered worktree for $BRANCH"
	else
		cleanup "local branch $BRANCH" "deleted local branch $BRANCH" remove_local_branch || :
	fi
fi
if [ -n "$REMOTE" ]; then
	cleanup "origin branch $BRANCH" "deleted origin branch $BRANCH" remove_origin_branch || :
fi

if [ -n "$failures" ]; then
	echo "failure: teardown failed: $failures" >&2
	exit 2
fi
if [ "$WITHDRAWN" -eq 1 ]; then
	printf 'withdrawal-cleaned %s\n' "$TICKET_ID"
fi
