#!/bin/sh
#
# Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
#
# SPDX-License-Identifier: BSD-2-Clause
#

# git-pr - Create and update pull requests via AGit flow
#
# Usage:
#   git pr <remote> <branch> [-t title] [-d description] [-f] [-n]
#
# By default, if no -t is given and a TTY is available, the editor
# is opened to compose the PR title and description. Use -t to skip
# the editor.
#
# Examples:
#   git pr origin main                          # open editor (if TTY)
#   git pr upstream main -t "Fix login bug"     # skip editor
#   git pr origin main -f                       # force push
#   git pr origin main -n                       # dry-run
#   git pr --status [remote]                    # check if HEAD is already pushed
#

set -eu
set -o pipefail

usage() {
	cat <<'EOF'
Usage: git pr <remote> <target-branch> [options]
       git pr <target-branch> [options]      (requires pr.remote in git config)

Create or update a pull request via AGit flow.
The topic branch defaults to the current branch name.

If no -t is given and stdin is a TTY, an editor is opened to
compose the PR title and description (like git commit).

Options:
  -t <title>        PR title (skips editor)
  -d <description>  PR description
  -o <topic>        Topic branch name (default: current branch)
  -f                Allow force push
  -n                Dry run (show what would be pushed)
  -h                Show this help

Other modes:
  git pr --status [remote] [target]
                             Query the Forgejo API for PRs whose head
                             branch matches the current topic. Default remote:
                             origin. Target (optional) is used to strip the
                             target/ prefix from the current branch.

  git pr --merge <num> [remote]
                             Fast-forward the PR's target branch locally onto
                             the PR head. Aborts if not fast-forwardable or
                             if the working tree is dirty. Does NOT push —
                             prints the push command to run afterwards.

  git pr --list [remote]     List all open pull requests on the remote
                             (default: origin).

Git config:
  pr.remote          Default remote when not given on the command line.
  forgejo.token      API token for --status / --list / --merge.

Token resolution for API calls (private repos or rate limits):
  1. git config --get forgejo.token
  2. \$FORGEJO_TOKEN
  3. anonymous

Requires: curl, jq.
EOF
	exit "${1:-0}"
}

# Parse a git remote URL into: scheme, host, owner, repo, api_base
# Supports: https://host/owner/repo(.git), ssh://git@host/owner/repo(.git),
#           git@host:owner/repo(.git)
parse_remote_url() {
	url="$1"
	url="${url%.git}"
	case "$url" in
	http://*|https://*)
		scheme="${url%%://*}"
		rest="${url#*://}"
		# strip optional user@ in HTTPS URL
		case "$rest" in
		*@*/*) rest="${rest#*@}";;
	        esac
		host="${rest%%/*}"
		path="${rest#*/}"
		;;
	ssh://*)
		rest="${url#ssh://}"
		case "$rest" in
		*@*) rest="${rest#*@}";;
		esac
		host="${rest%%/*}"
		path="${rest#*/}"
		scheme="https"
		;;
	*@*:*)
		rest="${url#*@}"
		host="${rest%%:*}"
		path="${rest#*:}"
		scheme="https"
		;;
	*)
		echo "error: cannot parse remote URL: $url" >&2
		return 1
		;;
	esac
	owner="${path%%/*}"
	repo="${path#*/}"
	repo="${repo%%/*}"
	if [ -z "$owner" ] || [ -z "$repo" ]; then
		echo "error: could not extract owner/repo from: $url" >&2
		return 1
	fi
	api_base="${scheme}://${host}/api/v1"
}

get_token() {
	token=$(git config --get forgejo.token 2>/dev/null || true)
	[ -n "$token" ] && return 0
	token="${FORGEJO_TOKEN:-}"
}

# Default remote: pr.remote (local then global git config), fallback "origin".
default_remote() {
	git config --get pr.remote 2>/dev/null || echo origin
}

api_setup() {
	command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
	command -v jq   >/dev/null 2>&1 || { echo "error: jq is required"   >&2; exit 1; }
	url=$(git remote get-url "$1" 2>/dev/null) || {
		echo "error: no such remote: $1" >&2; exit 1;
	}
	parse_remote_url "$url" || exit 1
	get_token
}

fetch_all_pulls() {
	state="$1"
	page=1
	limit=50
	all_json="[]"
	while :; do
		set -- -sS -w '\n%{http_code}'
		[ -n "$token" ] && set -- "$@" -H "Authorization: token $token"
		set -- "$@" "$api_base/repos/$owner/$repo/pulls?state=$state&limit=$limit&page=$page"

		resp=$(curl "$@") || { echo "error: API request failed" >&2; exit 1; }
		status=$(printf '%s' "$resp" | tail -n1)
		body=$(printf '%s' "$resp" | sed '$d')

		if [ "$status" != "200" ]; then
			printf 'error: API returned HTTP %s\n' "$status" >&2
			printf '%s\n' "$body" >&2
			exit 1
		fi

		count=$(printf '%s' "$body" | jq 'length')
		[ "$count" = "0" ] && break
		all_json=$(printf '%s\n%s\n' "$all_json" "$body" | jq -s '.[0] + .[1]')
		[ "$count" -lt "$limit" ] && break
		page=$((page + 1))
	done
}

pr_status() {
	remote="${1:-$(default_remote)}"
	target_arg="${2:-}"

	api_setup "$remote"

	topic=$(git symbolic-ref --short HEAD 2>/dev/null) || {
		echo "error: detached HEAD, cannot determine topic" >&2; exit 1;
	}
	if [ -n "$target_arg" ]; then
		case "$topic" in
		"$target_arg"/*) topic="${topic#"$target_arg"/}" ;;
		esac
	fi

	printf 'Querying %s/repos/%s/%s for PRs with head matching %s...\n' \
		"$api_base" "$owner" "$repo" "$topic"

	fetch_all_pulls all

	# Flexible head matching: exact, or suffix "<something>/<topic>"
	# (Forgejo may prefix AGit head refs with a user or generated segment).
	matches=$(printf '%s' "$all_json" | jq -r --arg t "$topic" '
		[.[] | select(.head.ref == $t or (.head.ref | endswith("/" + $t)))]
		| if length == 0 then empty
		  else .[] | "  PR #\(.number) [\(.state)] \(.title)\n    \(.html_url)\n    head=\(.head.ref)@\(.head.sha[0:7])  base=\(.base.ref)"
		  end
	')

	if [ -z "$matches" ]; then
		printf 'No PR found with head matching "%s" on %s/%s (scanned %s PRs)\n' \
			"$topic" "$owner" "$repo" "$(printf '%s' "$all_json" | jq 'length')"
		exit 0
	fi
	printf '%s\n' "$matches"
	exit 0
}

pr_list() {
	remote="${1:-$(default_remote)}"
	api_setup "$remote"

	printf 'Open pull requests on %s/%s:\n' "$owner" "$repo"
	fetch_all_pulls open

	total=$(printf '%s' "$all_json" | jq 'length')
	if [ "$total" = "0" ]; then
		echo "  (none)"
		exit 0
	fi

	printf '%s' "$all_json" | jq -r '
		sort_by(.number) | .[] |
		"  #\(.number)  \(.title)\n    by \(.user.login)  \(.head.ref) -> \(.base.ref)  \(.html_url)"
	'
	exit 0
}

# Fast-forward a PR's base branch onto the PR head, locally.
pr_merge() {
	num="${1:-}"
	remote="${2:-$(default_remote)}"

	case "$num" in
	''|*[!0-9]*)
		echo "error: --merge requires a numeric PR number" >&2; exit 1 ;;
	esac

	api_setup "$remote"

	# Fetch PR metadata
	set -- -sS -w '\n%{http_code}'
	[ -n "$token" ] && set -- "$@" -H "Authorization: token $token"
	set -- "$@" "$api_base/repos/$owner/$repo/pulls/$num"
	resp=$(curl "$@") || { echo "error: API request failed" >&2; exit 1; }
	status=$(printf '%s' "$resp" | tail -n1)
	body=$(printf '%s' "$resp" | sed '$d')
	if [ "$status" != "200" ]; then
		printf 'error: API returned HTTP %s\n' "$status" >&2
		printf '%s\n' "$body" >&2
		exit 1
	fi

	base=$(printf '%s' "$body" | jq -r '.base.ref')
	head_sha=$(printf '%s' "$body" | jq -r '.head.sha')
	state=$(printf '%s' "$body" | jq -r '.state')
	merged=$(printf '%s' "$body" | jq -r '.merged')
	title=$(printf '%s' "$body" | jq -r '.title')

	printf 'PR #%s: %s\n  state=%s  base=%s  head=%s\n' \
		"$num" "$title" "$state" "$base" "$(printf '%s' "$head_sha" | cut -c1-7)"

	[ "$merged" = "true" ] && { echo "error: PR already merged" >&2; exit 1; }
	[ "$state" = "open" ]  || { echo "error: PR is not open (state=$state)" >&2; exit 1; }

	if ! git diff --quiet || ! git diff --cached --quiet; then
		echo "error: working tree has uncommitted changes" >&2
		exit 1
	fi

	# Fetch PR head + base branch from remote
	printf 'Fetching PR head and %s from %s...\n' "$base" "$remote"
	git fetch "$remote" "refs/pull/$num/head:refs/remotes/$remote/pr/$num" "$base" \
		|| { echo "error: fetch failed" >&2; exit 1; }

	fetched_sha=$(git rev-parse "refs/remotes/$remote/pr/$num")
	if [ "$fetched_sha" != "$head_sha" ]; then
		printf 'warning: fetched head %s differs from API (%s) — using fetched\n' \
			"$(printf '%s' "$fetched_sha" | cut -c1-7)" \
			"$(printf '%s' "$head_sha" | cut -c1-7)" >&2
		head_sha="$fetched_sha"
	fi

	remote_base_sha=$(git rev-parse "refs/remotes/$remote/$base" 2>/dev/null) || {
		echo "error: cannot resolve $remote/$base after fetch" >&2; exit 1;
	}

	# Verify fast-forward is possible
	if ! git merge-base --is-ancestor "$remote_base_sha" "$head_sha"; then
		printf 'error: PR #%s is not fast-forwardable onto %s\n' "$num" "$base" >&2
		printf '       %s has diverged from the PR base.\n' "$base" >&2
		exit 1
	fi

	# Checkout base branch (create if missing, tracking the remote)
	if git show-ref --verify --quiet "refs/heads/$base"; then
		git checkout "$base"
		# Ensure local base is not ahead of or diverged from remote/base
		local_base_sha=$(git rev-parse HEAD)
		if [ "$local_base_sha" != "$remote_base_sha" ]; then
			if ! git merge-base --is-ancestor "$local_base_sha" "$remote_base_sha"; then
				echo "error: local $base has diverged from $remote/$base" >&2
				exit 1
			fi
			git merge --ff-only "refs/remotes/$remote/$base" \
				|| { echo "error: could not ff local $base to $remote/$base" >&2; exit 1; }
		fi
	else
		git checkout -b "$base" "refs/remotes/$remote/$base"
	fi

	git merge --ff-only "$head_sha" \
		|| { echo "error: fast-forward merge failed" >&2; exit 1; }

	printf '\nLocal %s fast-forwarded to PR #%s head (%s).\n' \
		"$base" "$num" "$(printf '%s' "$head_sha" | cut -c1-7)"
	printf 'To publish:\n  git push %s %s\n' "$remote" "$base"
	exit 0
}

# Base64-encode stdin without line wrapping. Tries `base64` first, falls back
# to `openssl base64`. Used to smuggle newlines through git push options via
# Forgejo's "{base64}..." prefix on the title/description AGit options.
b64encode_stdin() {
	if command -v base64 >/dev/null 2>&1; then
		base64 | tr -d '\n'
	elif command -v openssl >/dev/null 2>&1; then
		openssl base64 | tr -d '\n'
	else
		echo "error: need 'base64' or 'openssl' to encode multi-line description" >&2
		return 1
	fi
}

git_editor() {
	GIT_EDITOR=$(git var GIT_EDITOR 2>/dev/null) || \
	GIT_EDITOR="${VISUAL:-${EDITOR:-vi}}"
}

# Open an editor with a PR template, parse the result.
# Sets $title and $description.
edit_pr_message() {
	msgfile=$(mktemp "${TMPDIR:-/tmp}/git-pr.XXXXXX")
	trap 'rm -f "$msgfile"' EXIT

	# Pre-fill from commit message
	default_title=$(git log -1 --pretty=%s HEAD)
	default_desc=$(git log -1 --pretty=%b HEAD)

	cat > "$msgfile" <<EOF
${default_title}

${default_desc}
# Enter the pull request title on the first line above.
# Everything after the first blank line is the description.
# Lines starting with '#' will be ignored.
#
# Target: ${target}
# Topic:  ${topic}
#
# Changes to be submitted:
EOF
	git log --oneline "${target}..HEAD" 2>/dev/null \
		| sed 's/^/#   /' >> "$msgfile" || true

	git_editor
	$GIT_EDITOR "$msgfile" < /dev/tty > /dev/tty

	# Strip comments, first line = title, rest = description
	cleaned=$(sed '/^#/d' "$msgfile")
	# sed -n '1p' rather than 'sed 1q' so the producer isn't SIGPIPE'd
	# under "set -o pipefail".
	title=$(printf '%s\n' "$cleaned" | sed -n '1p')
	description=$(printf '%s\n' "$cleaned" | sed '1,2d')

	# Trim trailing blank lines
	description=$(printf '%s\n' "$description" | sed '
:a
/^[[:space:]]*$/{
$d
N
ba
}
')

	if [ -z "$title" ]; then
		echo "error: empty title, aborting" >&2
		exit 1
	fi
}

if [ "${1:-}" = "--status" ]; then
	shift
	pr_status "$@"
fi

if [ "${1:-}" = "--merge" ]; then
	shift
	pr_merge "$@"
fi

if [ "${1:-}" = "--list" ]; then
	shift
	pr_list "$@"
fi

if [ $# -lt 1 ] || [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
	usage
fi

# Two forms:
#   git pr <remote> <target> [opts]
#   git pr <target> [opts]            (requires pr.remote in git config)
# We pick the second form when only one positional is present (i.e. $2 is
# missing or starts with '-'), and pr.remote is configured.
if [ $# -ge 2 ] && [ "${2#-}" = "$2" ]; then
	remote="$1"
	target="$2"
	shift 2
else
	configured_remote=$(git config --get pr.remote 2>/dev/null || true)
	if [ -z "$configured_remote" ]; then
		echo "error: missing <remote>; pass it explicitly or set pr.remote" >&2
		usage 1
	fi
	remote="$configured_remote"
	target="$1"
	shift 1
fi

title=""
description=""
topic=""
force=false
dryrun=false

while getopts "t:d:o:fnh" opt; do
	case "$opt" in
	t) title="$OPTARG" ;;
	d) description="$OPTARG" ;;
	o) topic="$OPTARG" ;;
	f) force=true ;;
	n) dryrun=true ;;
	h) usage ;;
	*) usage 1 ;;
	esac
done

# Default topic to current branch name
if [ -z "$topic" ]; then
	topic=$(git symbolic-ref --short HEAD 2>/dev/null) || {
		echo "error: cannot determine current branch (detached HEAD?)" >&2
		exit 1
	}
	# Strip the target prefix if the branch is already named like target/topic
	case "$topic" in
	"$target"/*)
		topic="${topic#"$target"/}"
		;;
	esac
fi

# Open editor if no title given and we have a TTY
if [ -z "$title" ] && [ -t 0 ]; then
	edit_pr_message
fi

# Git refuses push options containing newlines. Forgejo (>= v13.0.0) accepts
# "title={base64}..." and "description={base64}..." which it decodes server
# side, so base64-encode any value containing a newline before pushing.
if [ -n "$title" ]; then
	case "$title" in
	*"
"*)
		encoded=$(printf '%s' "$title" | b64encode_stdin) || exit 1
		title="{base64}$encoded"
		;;
	esac
fi
if [ -n "$description" ]; then
	case "$description" in
	*"
"*)
		encoded=$(printf '%s' "$description" | b64encode_stdin) || exit 1
		description="{base64}$encoded"
		;;
	esac
fi

# Build push options
set -- push
if [ -n "$title" ]; then
	set -- "$@" -o "title=$title"
fi
if [ -n "$description" ]; then
	set -- "$@" -o "description=$description"
fi
if [ "$force" = true ]; then
	set -- "$@" -o force-push=true
fi

refspec="HEAD:refs/for/${target}/${topic}"

set -- "$@" "$remote" "$refspec"

if [ "$dryrun" = true ]; then
	printf 'git'
	for arg in "$@"; do printf ' %s' "$arg"; done
	printf '\n'
	exit 0
fi

exec git "$@"
