#!/bin/bash
# grant-reviewer.sh - Grant a reviewer read + review access to an org-owned
# Gitea repository via an org team (default: fleet-reviewers).
#
# Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]
#
# The team carries `permission: read` with per-unit overrides
# {repo.code: read, repo.issues: write, repo.pulls: write}: the reviewer can
# read code and write issues/PR reviews, but cannot push. The grant is
# idempotent — the team is looked up before it is created, and member/repo
# additions are PUTs.
#
# KNOWN LIMITATION — branch protection counts these reviews as UNOFFICIAL.
# Gitea computes a review's `official` flag at SUBMISSION time, from write
# permission on the repo or from membership in the protected branch's
# approvals whitelist (disabled by default). A team granted through this
# script has read permission on code, so under branch protection with
# required_approvals the reviewer's approval shows but does NOT count toward
# the required total — the merge still fails with "not enough approvals".
# Enabling the approvals whitelist and adding this team to it is review
# policy (who counts as an official approver), an operator decision made in
# the repo's branch-protection settings, deliberately NOT automated here.
# Because `official` is fixed at submission, whitelisting after the fact
# requires the review to be re-submitted before it counts.
#
# Platform: Gitea only. On a GitHub-remoted repo this script refuses to run —
# GitHub review access is granted through collaborator/team facilities that
# have no equivalent to Gitea's org-team unit map.
#
# Identity: the acting credential resolves exactly as in issue-comment.sh —
# GITEA_LOGIN (when set) names a tea login whose token MUST resolve for the
# remote host (fail closed, never downgrade to the host default identity);
# otherwise the per-seat identity ladder in detect-platform.sh applies
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity → per-slot token,
# fail-loud on fleet hosts). Managing org teams requires org owner/admin:
# an HTTP 403 from any step is reported as "org admin required on <org>",
# never as a silent partial grant.
#
# Verification is fail-closed: after the member and repo PUTs, the script
# GETs the single resources back (GET /teams/{id}/members/{user} and
# GET /teams/{id}/repos/{owner}/{repo}) and refuses to report success unless
# both confirm the grant. A PUT that returns success without persisting
# (the #865 defect class: an exit code is not evidence of a durable write)
# therefore fails the run instead of reporting a grant that does not exist.

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"

usage() {
    echo "Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]"
    echo ""
    echo "Options:"
    echo "  -u, --user   Gitea username to grant reviewer access (required)"
    echo "  -r, --repo   Target repository as <owner>/<repo>; defaults to the"
    echo "               current repository's origin. The owner must be an"
    echo "               organization."
    echo "  -t, --team   Org team to use/create (default: fleet-reviewers)"
    echo "  -h, --help   Show this help"
    echo ""
    echo "Environment:"
    echo "  GITEA_LOGIN  Override the acting identity with a named tea login"
    echo "               (must resolve for the remote host; fails closed)."
    echo ""
    echo "Grants: code read + issues/pulls write via an org team. Gitea only."
    echo ""
    echo "LIMITATION: under branch protection with required approvals, reviews"
    echo "from a read-permission team are official=false and do not count"
    echo "toward the required total. Making them count means enabling the"
    echo "protected branch's approvals whitelist and adding the team — an"
    echo "operator review-policy decision this script does not automate. The"
    echo "official flag is computed at review submission, so a review made"
    echo "before whitelisting must be re-submitted afterwards."
}

REVIEWER=""
REPO_OVERRIDE=""
TEAM="fleet-reviewers"

while [[ $# -gt 0 ]]; do
    case $1 in
        -u|--user)
            REVIEWER="$2"
            shift 2
            ;;
        -r|--repo)
            REPO_OVERRIDE="$2"
            shift 2
            ;;
        -t|--team)
            TEAM="$2"
            shift 2
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

if [[ -z "$REVIEWER" ]]; then
    echo "Error: reviewer username is required (-u)" >&2
    exit 1
fi

# Gitea usernames and team names are AlphaDashDot. Validating here keeps the
# values safe to interpolate into API paths without URL-encoding.
NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'
if ! [[ "$REVIEWER" =~ $NAME_RE ]]; then
    echo "Error: invalid reviewer username '$REVIEWER'" >&2
    exit 1
fi
if ! [[ "$TEAM" =~ $NAME_RE ]]; then
    echo "Error: invalid team name '$TEAM'" >&2
    exit 1
fi
if [[ -n "$REPO_OVERRIDE" ]] && ! [[ "$REPO_OVERRIDE" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
    echo "Error: -r expects <owner>/<repo>, got '$REPO_OVERRIDE'" >&2
    exit 1
fi

detect_platform >/dev/null

if [[ "$PLATFORM" != "gitea" ]]; then
    echo "Error: grant-reviewer.sh is Gitea only (detected platform: $PLATFORM)." >&2
    echo "       On GitHub, grant review access via repository collaborators or org teams in the GitHub UI/CLI." >&2
    exit 1
fi

HOST=$(get_remote_host) || {
    echo "Error: could not resolve the remote host from origin" >&2
    exit 1
}

# Acting credential: GITEA_LOGIN (explicit, fail closed) or the identity
# ladder. Same ordering contract as issue-comment.sh — an explicit override is
# never silently downgraded to the host default identity.
if [[ -n "${GITEA_LOGIN:-}" ]]; then
    GITEA_API_TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN" "$HOST") || {
        echo "Error: could not resolve a host-matched Gitea token for GITEA_LOGIN '$GITEA_LOGIN' on host '$HOST'; refusing to fall back to the host default identity (reviewer grant)" >&2
        exit 1
    }
else
    GITEA_API_TOKEN=$(get_gitea_token "$HOST") || {
        echo "Error: no Gitea credential resolved for the acting identity on host '$HOST' (reviewer grant). Set MOSAIC_GIT_IDENTITY=<agent-id>, or set GITEA_LOGIN=<name> to use a named tea credential." >&2
        exit 1
    }
fi

CONFIGURED_URL=$(get_gitea_url_for_host "$HOST") || {
    echo "Error: configured Gitea URL not found for host '$HOST'" >&2
    exit 1
}
GITEA_API_ROOT="${CONFIGURED_URL%/}/api/v1"

if [[ -n "$REPO_OVERRIDE" ]]; then
    REPO_SLUG="$REPO_OVERRIDE"
else
    REPO_SLUG=$(get_gitea_repo_slug_for_url "$CONFIGURED_URL") || {
        echo "Error: could not resolve <owner>/<repo> from origin; pass -r <owner>/<repo>" >&2
        exit 1
    }
fi
ORG="${REPO_SLUG%%/*}"
REPO_NAME="${REPO_SLUG#*/}"

RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX")
AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
    rm -f "$RESPONSE_FILE"
    echo "Error: could not stage Gitea credential for reviewer grant" >&2
    exit 1
}
trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT

# gitea_api <step> <method> <path> [json-payload]
# Runs one API call with the staged credential (token never in argv). Sets
# GITEA_API_STATUS and leaves the body in $RESPONSE_FILE. Transport failure
# and HTTP 403 are terminal here: 403 on ANY step means the acting identity
# cannot manage org teams, and the run must stop rather than continue into a
# partial grant.
gitea_api() {
    local step="$1" method="$2" path="$3" payload="${4:-}"
    local -a payload_args=()
    if [[ -n "$payload" ]]; then
        payload_args=(-H 'Content-Type: application/json' -d "$payload")
    fi
    if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
        -X "$method" \
        --config "$AUTH_CONFIG" \
        "${payload_args[@]}" \
        "$GITEA_API_ROOT$path"); then
        echo "Error: Gitea transport failed during $step" >&2
        return 1
    fi
    if [[ "$GITEA_API_STATUS" == "403" ]]; then
        echo "Error: HTTP 403 during $step: org admin required on '$ORG' — managing org teams needs owner/admin on the organization. No grant was completed." >&2
        return 1
    fi
    return 0
}

# json_field <file> <key> — print a top-level scalar field or fail.
json_field() {
    python3 - "$1" "$2" <<'PY'
import json
import sys

try:
    with open(sys.argv[1], encoding="utf-8") as response:
        data = json.load(response)
    value = data.get(sys.argv[2]) if isinstance(data, dict) else None
    if value is None or isinstance(value, (dict, list, bool)):
        raise ValueError(f"missing or non-scalar field {sys.argv[2]!r}")
except (OSError, json.JSONDecodeError, ValueError) as error:
    print(f"Error: unusable Gitea response: {error}", file=sys.stderr)
    raise SystemExit(1)
print(value)
PY
}

# 1. The owner must be an organization: teams are an org facility, and a
#    user-owned repo would fail later with a misleading team error.
gitea_api "organization check" GET "/orgs/$ORG"
if [[ "$GITEA_API_STATUS" == "404" ]]; then
    echo "Error: owner '$ORG' is not an organization on '$HOST'; grant-reviewer requires an org-owned repository" >&2
    exit 1
fi
if [[ "$GITEA_API_STATUS" != "200" ]]; then
    echo "Error: organization check for '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
    exit 1
fi

# 2. Idempotent team resolution: exact-name lookup first, create only on miss.
#    The search endpoint substring-matches, so the exact-name filter is done
#    on the response, not trusted to the query.
gitea_api "team lookup" GET "/orgs/$ORG/teams/search?q=$TEAM"
if [[ "$GITEA_API_STATUS" != "200" ]]; then
    echo "Error: team lookup for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
    exit 1
fi
TEAM_ID=$(TEAM_NAME="$TEAM" python3 - "$RESPONSE_FILE" <<'PY'
import json
import os
import sys

wanted = os.environ["TEAM_NAME"]
try:
    with open(sys.argv[1], encoding="utf-8") as response:
        result = json.load(response)
    teams = result.get("data") if isinstance(result, dict) else None
    if not isinstance(teams, list):
        raise ValueError("team search response carried no data list")
except (OSError, json.JSONDecodeError, ValueError) as error:
    print(f"Error: unusable team search response: {error}", file=sys.stderr)
    raise SystemExit(1)
for team in teams:
    if isinstance(team, dict) and team.get("name") == wanted:
        team_id = team.get("id")
        if not isinstance(team_id, int) or team_id <= 0:
            print("Error: matched team carried no positive id", file=sys.stderr)
            raise SystemExit(1)
        print(team_id)
        raise SystemExit(0)
print("")
PY
)

if [[ -z "$TEAM_ID" ]]; then
    CREATE_PAYLOAD=$(TEAM_NAME="$TEAM" python3 -c '
import json
import os

print(json.dumps({
    "name": os.environ["TEAM_NAME"],
    "description": "review seats: code read + issues/pulls write",
    "permission": "read",
    "includes_all_repositories": False,
    "can_create_org_repo": False,
    "units_map": {
        "repo.code": "read",
        "repo.issues": "write",
        "repo.pulls": "write",
    },
}))
')
    gitea_api "team create" POST "/orgs/$ORG/teams" "$CREATE_PAYLOAD"
    if [[ "$GITEA_API_STATUS" != "201" ]]; then
        echo "Error: team create for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
        exit 1
    fi
    TEAM_ID=$(json_field "$RESPONSE_FILE" id) || {
        echo "Error: team create returned no usable team id" >&2
        exit 1
    }
    echo "Created team '$TEAM' (id $TEAM_ID) on org '$ORG'"
else
    echo "Found existing team '$TEAM' (id $TEAM_ID) on org '$ORG'"
fi

# 3. Membership and repo attachment — both PUTs, both idempotent in Gitea.
gitea_api "member add" PUT "/teams/$TEAM_ID/members/$REVIEWER"
if [[ "$GITEA_API_STATUS" != "204" ]]; then
    echo "Error: adding '$REVIEWER' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
    exit 1
fi

gitea_api "repo add" PUT "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
if [[ "$GITEA_API_STATUS" != "204" ]]; then
    echo "Error: adding repo '$REPO_SLUG' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
    exit 1
fi

# 4. Fail-closed read-back: a 204 from a PUT is an exit code, not evidence the
#    grant persisted. GET the single resources back and require both.
gitea_api "member read-back" GET "/teams/$TEAM_ID/members/$REVIEWER"
if [[ "$GITEA_API_STATUS" != "200" ]]; then
    echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/members/$REVIEWER returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
    exit 1
fi
READBACK_LOGIN=$(json_field "$RESPONSE_FILE" login) || exit 1
if [[ "${READBACK_LOGIN,,}" != "${REVIEWER,,}" ]]; then
    echo "Error: reviewer grant NOT verified — member read-back returned login '$READBACK_LOGIN', expected '$REVIEWER'" >&2
    exit 1
fi

gitea_api "repo read-back" GET "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
if [[ "$GITEA_API_STATUS" != "200" ]]; then
    echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/repos/$ORG/$REPO_NAME returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
    exit 1
fi
READBACK_FULL_NAME=$(json_field "$RESPONSE_FILE" full_name) || exit 1
if [[ "${READBACK_FULL_NAME,,}" != "${REPO_SLUG,,}" ]]; then
    echo "Error: reviewer grant NOT verified — repo read-back returned '$READBACK_FULL_NAME', expected '$REPO_SLUG'" >&2
    exit 1
fi

echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back"
echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."
