#!/bin/bash
set -euo pipefail

GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"

# The marker saying an agent wrote the body is deliberately not here. It is
# applied by docker/forge-guard.sh, which shadows this command inside the
# sandbox, so that it reaches every forge and not only the ones whose CLI
# dejima happens to have written. Outside a sandbox this tool is being run by
# a person, and a person's body is not agent-generated.

function usage() {
    echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
    echo "       gitea-pr edit PR_NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
    echo "       gitea-pr comments PR_NUMBER [--all]"
    echo "       gitea-pr merge PR_NUMBER"
    echo ""
    echo "'comments' shows unresolved review threads by default. --all includes"
    echo "the ones already resolved, marked as such."
    echo ""
    echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
    echo "Prefer it over --body for anything with quotes, backticks or newlines."
    echo ""
    echo "'edit' replaces the title and/or body of an existing PR. Whichever of"
    echo "the two is not given is left as it is."
    echo ""
    echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
         "your shell profile), and a token: GITEA_TOKEN if it is set, otherwise" \
         "the contents of $GITEA_TOKEN_FILE. Inside a dejima sandbox the" \
         "variable is the arrangement - the file is deliberately not there."
}

function require_config() {
    if [ -z "${GITEA_SSH_HOST:-}" ]; then
        echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
             "(e.g. gitea.example.com) to ~/.profile.local" >&2
        exit 1
    fi
    if [ -z "${GITEA_API:-}" ]; then
        echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
             "(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
        exit 1
    fi
}

function require_token() {
    if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
        GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
    fi
    if [ -z "${GITEA_TOKEN:-}" ]; then
        if [ -n "${DEJIMA:-}" ]; then
            # In a sandbox the token arrives as GITEA_TOKEN, read host-side from
            # whatever the forge's token_file names. Writing the file suggested
            # below would satisfy this check and nothing else: the container is
            # disposable, and the operator's real credential is not in it. Say
            # what has to change instead, which is outside.
            echo "No Gitea token in this sandbox. It arrives as GITEA_TOKEN," \
                 "read on the host from the token_file the configured forge" \
                 "names - so there is nothing to create in here, and writing" \
                 "$GITEA_TOKEN_FILE would not help." >&2
            echo "" >&2
            echo "Most likely this session has no forge configured, or the" \
                 "configured one is not gitea. Ask the operator to check" \
                 "[core] forge and [forge.<name>] token_file." >&2
        else
            echo "No Gitea token found. Generate one in the Gitea web UI" \
                 "(Settings > Applications, scopes write:repository +" \
                 "write:issue) and save it to $GITEA_TOKEN_FILE (chmod 600)," \
                 "or set GITEA_TOKEN in the environment." >&2
        fi
        exit 1
    fi
}

# Feeds the auth header to curl as a config on stdin rather than as an
# argument. Command lines are world-readable through /proc, so `-H
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
# process on the host for as long as the request runs - and to anything that
# scrapes `ps` output, including the agents this repo exists to sandbox.
# printf is a bash builtin, so the token never lands in another argv.
function gitea_api() {
    printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
        curl -sS -w '\n%{http_code}' -K - "$@"
}

# Splits gitea_api's combined output into the globals `json` and `http_code`
# and fails with the body on any non-2xx. Copied from gitea-issue, which grew
# it first for the same reason: every call site here did this by hand, and a
# fifth hand-rolled copy is where one of them quietly stops checking.
function api_or_die() {
    local what="$1"; shift
    local response
    response=$(gitea_api "$@")
    http_code=$(echo "$response" | tail -1)
    json=$(echo "$response" | sed '$d')
    if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
        echo "Failed to $what (HTTP $http_code):" >&2
        echo "$json" >&2
        exit 1
    fi
}

# Same check, but an absent argument gets the usage line rather than a
# complaint about the empty string: "no argument" and "wrong argument" are
# different mistakes and deserve different advice.
function require_number_or_usage() {
    [ -n "${1:-}" ] || return 0
    require_number "$1"
}

# Reads a body from a file, or from stdin for '-'. The point is to keep prose
# out of argv: a body composed on a command line has to survive shell quoting,
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
# what English and code fences are made of. A file (or a heredoc into '-') has
# no such layer.
function read_body_file() {
    local file="$1"
    if [ "$file" = "-" ]; then
        cat
        return
    fi
    if [ ! -f "$file" ]; then
        echo "No such body file: $file" >&2
        exit 1
    fi
    cat "$file"
}

function gitea_owner_repo() {
    local remote_url host owner_repo
    remote_url=$(git remote get-url origin 2>/dev/null) || {
        echo "No 'origin' remote found in this repository" >&2
        exit 1
    }
    host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
    if [ "$host" != "$GITEA_SSH_HOST" ]; then
        echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-pr only supports this host" >&2
        exit 1
    fi
    owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
    echo "$owner_repo"
}

# Gitea paginates every list endpoint and answers a bare request with one
# page. A caller that reads page one and stops is correct only while the list
# is short and silently wrong afterwards, which is the worst shape for this:
# `create`'s duplicate check would miss an existing PR and file a second one,
# and `comments` would drop the review someone is waiting on, both without a
# word. Sets `json` to the concatenation of every page, so callers keep
# treating it as one array.
#
# Pages are followed until one comes back short, which is the end of the list.
# PAGE_MAX bounds the loop rather than trusting that: a server that ignores
# `page` would otherwise return a full page forever, and this runs
# unattended.
PAGE_LIMIT=50
PAGE_MAX=40
function api_all_pages() {
    local what="$1" url="$2" sep page=1 count acc='[]'
    case "$url" in *\?*) sep='&' ;; *) sep='?' ;; esac
    while [ "$page" -le "$PAGE_MAX" ]; do
        api_or_die "$what" "${url}${sep}limit=${PAGE_LIMIT}&page=${page}"
        count=$(echo "$json" | jq 'if type == "array" then length else 0 end')
        acc=$(jq -n --argjson a "$acc" --argjson b "$json" \
            '$a + (if ($b | type) == "array" then $b else [] end)')
        [ "$count" -eq "$PAGE_LIMIT" ] || break
        page=$((page + 1))
    done
    if [ "$page" -gt "$PAGE_MAX" ]; then
        echo "warning: stopped reading $what after $PAGE_MAX pages - some may be missing" >&2
    fi
    json="$acc"
}

# Every subcommand taking a PR number goes through this, not only the ones
# where a bad value would be obvious. The number is interpolated into an API
# URL path and curl normalises dot-segments, so a value like
# `../../../other/repo/pulls/7` reaches a different repo's endpoint with this
# token on it. No privilege boundary is crossed - the agent holds the token
# and could curl directly - but a CLI that builds the URL should not be the
# thing that makes it easy.
function require_number() {
    if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
        echo "Expected a PR number, got '${1:-}'" >&2
        exit 1
    fi
}

# Sets `body` from --body/--body-file, `parsed_title` from --title and
# `parsed_base` from --base, rejecting --body and --body-file together.
# Shared by create and edit so the two cannot drift into disagreeing about
# which one wins - the same reason gitea-issue shares one between its own
# two body-taking subcommands.
function parse_body_args() {
    body=""
    local body_file=""
    parsed_title=""
    parsed_base=""
    while [ "$#" -gt 0 ]; do
        case "$1" in
            --title) parsed_title="$2"; shift 2 ;;
            --body) body="$2"; shift 2 ;;
            --body-file) body_file="$2"; shift 2 ;;
            --base) parsed_base="$2"; shift 2 ;;
            *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
        esac
    done
    # Rejected rather than ranked: silently preferring one would make a caller
    # that passed both ship a body it didn't mean to.
    if [ -n "$body" ] && [ -n "$body_file" ]; then
        echo "Pass --body or --body-file, not both" >&2
        exit 1
    fi
    if [ -n "$body_file" ]; then
        body="$(read_body_file "$body_file")"
    fi
}

if [ "$#" -lt 1 ]; then
    usage
    exit 1
fi

subcommand="$1"
shift

case "$subcommand" in
    create|edit|comments|merge)
        require_config
        require_token
        owner_repo=$(gitea_owner_repo)
        owner="${owner_repo%%/*}"
        repo="${owner_repo##*/}"
        ;;
    *)
        usage
        exit 1
        ;;
esac

case "$subcommand" in
    create)
        parse_body_args "$@"
        title="$parsed_title"
        base="$parsed_base"

        current_branch=$(git rev-parse --abbrev-ref HEAD)
        if [ "$current_branch" = "HEAD" ]; then
            echo "Detached HEAD: check out the feature branch before creating a PR" >&2
            exit 1
        fi

        # Where the base comes from when --base was not given, most specific
        # first. A project whose work does not target its default branch - one
        # that merges through a development branch, say - can say so once with
        #
        #     git config gitea.prBase development
        #
        # rather than every caller remembering a flag. Without that, the
        # repository's default branch is the only thing there is to go on.
        base_source="--base"
        if [ -z "$base" ]; then
            base="$(git config --get gitea.prBase 2>/dev/null)" || base=""
            base_source="git config gitea.prBase"
        fi
        if [ -z "$base" ]; then
            base=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's#refs/remotes/origin/##' || true)
            base_source="the repository's default branch"
        fi
        if [ -z "$base" ]; then
            base=main
            base_source="a fallback, because nothing else said"
        fi
        # Said out loud, every time. A base that is wrong is not visible in
        # anything this prints afterwards - the URL looks the same - so it was
        # possible to open a run of pull requests against the wrong branch,
        # report them as targeting the right one in good faith, and have
        # someone else quietly retarget each one. Naming it here is what makes
        # the mistake cost one line instead of a fortnight.
        echo "gitea-pr: opening against '$base' ($base_source)." >&2

        api_all_pages "check for an existing PR" "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open"
        existing_url=$(echo "$json" | jq -r --arg head "$current_branch" --arg base "$base" \
            '.[] | select(.head.ref == $head and .base.ref == $base) | .html_url' | head -1)
        if [ -n "$existing_url" ]; then
            echo "$existing_url"
            exit 0
        fi
        if [ -z "$title" ]; then
            title="$current_branch"
        fi

        api_or_die "create PR" -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
            -H "Content-Type: application/json" \
            -d "$(jq -n --arg head "$current_branch" --arg base "$base" --arg title "$title" --arg body "$body" \
                '{head: $head, base: $base, title: $title, body: $body}')"
        echo "$json" | jq -r '.html_url'
        ;;
    edit)
        require_number "${1:-}"
        pr_number="$1"
        shift
        parse_body_args "$@"
        if [ -n "$parsed_base" ]; then
            echo "gitea-pr edit cannot change the base branch" >&2
            exit 1
        fi
        if [ -z "$parsed_title" ] && [ -z "$body" ]; then
            echo "gitea-pr edit requires --title, --body or --body-file" >&2
            exit 1
        fi

        # Only the fields actually given are sent: an omitted key leaves that
        # field alone, so editing a title cannot blank a body by accident.
        payload='{}'
        if [ -n "$parsed_title" ]; then
            payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
        fi
        if [ -n "$body" ]; then
            payload=$(echo "$payload" | jq --arg b "$body" '. + {body: $b}')
        fi

        api_or_die "edit PR #${pr_number}" -X PATCH \
            "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}" \
            -H "Content-Type: application/json" -d "$payload"
        echo "$json" | jq -r '.html_url'
        ;;
    comments)
        require_number "${1:-}"
        pr_number="$1"
        shift || true
        show_all=0
        while [ $# -gt 0 ]; do
            case "$1" in
                --all) show_all=1 ;;
                *) echo "Unknown argument: $1" >&2
                   echo "Usage: gitea-pr comments PR_NUMBER [--all]" >&2
                   exit 1 ;;
            esac
            shift
        done
        if [ -z "$pr_number" ]; then
            echo "Usage: gitea-pr comments PR_NUMBER [--all]" >&2
            exit 1
        fi

        echo "=== Issue comments ==="
        api_all_pages "fetch issue comments" \
            "${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments"
        echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'

        echo "=== Reviews ==="
        api_all_pages "fetch reviews" "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews"
        reviews_json="$json"
        echo "$reviews_json" | jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'

        echo "=== Inline review comments ==="
        review_ids=$(echo "$reviews_json" | jq -r '.[] | select(.comments_count > 0) | .id')
        all_comments='[]'
        for review_id in $review_ids; do
            api_all_pages "fetch comments for review ${review_id}" \
                "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments"
            # The hunk, not just the path. Gitea's review-comment objects carry
            # no `line` field at all, but they do carry `diff_hunk`, which is the exact
            # text the comment was left against. Without it a reviewer's
            # "this is confusing" arrives with nothing to attach it to, and
            # whoever acts on the review has to guess which passage was meant.
            # `position` is the index into the diff rather than a file line, so
            # it is printed as a diff offset and not as something to jump to.
            all_comments=$(printf '%s\n%s' "$all_comments" "$json" | jq -s 'add')
        done

        # A thread is every comment sharing a path and a diff position, and it
        # is resolved if ANY of them carries a resolver. Gitea stamps only a
        # thread's root when you resolve it, so a reply inside a closed thread
        # comes back with `resolver: null` and reads as open if each comment is
        # judged alone. That is not hypothetical: it is how a closed-and-refiled
        # thread on #96 presented as still needing an answer.
        #
        # Note what is NOT used here. A review's `state` (COMMENT vs
        # REQUEST_CHANGES) and whether comments arrived grouped under one review
        # or singly both describe how the reviewer submitted, not whether
        # anything is outstanding. Reading either as a resolution signal works
        # until a reviewer changes how they file, and then it silently stops.
        threads=$(printf '%s' "$all_comments" | jq '
            group_by(.path + "@" + (.position | tostring))
            | map({resolved: any(.[]; .resolver != null), comments: .})')
        resolved_count=$(printf '%s' "$threads" | jq '[.[] | select(.resolved)] | length')

        if [ "$show_all" -eq 1 ]; then
            visible="$threads"
        else
            visible=$(printf '%s' "$threads" | jq '[.[] | select(.resolved | not)]')
        fi

        printf '%s' "$visible" | jq -r '.[]
            | .resolved as $r
            | .comments[]
            | "[\(.user.login) @ \(.path) @@\(.position // "?")\(if $r then "  (resolved)" else "" end)]\n\(.diff_hunk // "(no hunk recorded)")\n--- comment ---\n\(.body)\n"'

        if [ "$show_all" -ne 1 ] && [ "$resolved_count" -gt 0 ]; then
            echo "($resolved_count resolved thread(s) hidden. Re-run with --all to include them.)"
        fi
        ;;
    merge)
        require_number_or_usage "${1:-}"
        pr_number="${1:-}"
        if [ -z "$pr_number" ]; then
            echo "Usage: gitea-pr merge PR_NUMBER" >&2
            exit 1
        fi

        api_or_die "merge PR #${pr_number}" -X POST \
            "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
            -H "Content-Type: application/json" -d '{"Do": "merge"}'
        echo "Merged PR #${pr_number}"
        ;;
esac
