#!/bin/bash

## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
## See the file COPYING for copying conditions.

## AI-Assisted

## Shared helpers for the policy-applying tools (dm-github-org-policy,
## dm-github-personal-policy). Sits on top of github-org-lib.bsh
## (API client: auth, retry, sanitization, validation) and adds:
##
##   - policy_api_call:  dry-run-aware wrapper that prints
##                       ok / warn / DRY-RUN labels and bumps the
##                       caller-scope warn flag on failure.
##   - policy_upsert_*_ruleset:
##                       idempotent find-by-name then PUT-update or
##                       POST-create for org-level and repo-level
##                       rulesets.
##   - policy_branch_ruleset_body / policy_tag_ruleset_body:
##                       parameterized factories for the branch and
##                       tag rulesets.
##   - policy_parse_mode_args / policy_tool_init:
##                       shared --apply / --dry-run / --audit / -v
##                       parser plus post-parse boilerplate.
##
## Static policy body data lives in github-policy-data.bsh.
##
## Caller-scope (bash dynamic scoping):
##
##   ${dry_run}    0|1   - policy_api_call prints planned call only
##                         when 1.
##   ${policy_warn_seen}  - every warn path sets this to '1'. Caller
##                          initializes to '0' before the apply loop
##                          and tests after; one-bit "did at least
##                          one warn happen anywhere".
##
## Optional caller-set globals:
##
##   ${POLICY_QUIET_OK}   - if '1', the 'ok:' line is suppressed.

## Source via:
##   source /usr/libexec/developer-meta-files/github-policy-lib.bsh
##
## github-org-lib.bsh must be sourced before this file.

set -o errexit
set -o nounset
set -o pipefail
set -o errtrace
shopt -s inherit_errexit
shopt -s shift_verbose

# shellcheck source=../../../../helper-scripts/usr/libexec/helper-scripts/strings.bsh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/strings.bsh

## ============================================================================
## API call wrapper
## ============================================================================

## Dry-run-aware wrapper around ghorg_api. Sets caller-scope
## ${policy_warn_seen}=1 on failure.
##
## Args:
##   $1 - human-readable label
##   $2 - HTTP method
##   $3 - URL path (relative to ${GHORG_API})
##   $4 - JSON body (optional)
##   $5 - one extra HTTP status code to accept as success (optional;
##        single code only - the case statement expands it as a
##        literal glob pattern). Use for endpoints where a non-2xx is
##        a documented "nothing to do" - e.g. Pages DELETE returns 404
##        when there's no Pages site.
##   $6 - body capture variable name (optional). When non-empty, the
##        response body is written into that caller-scope variable on
##        a 2xx (or extra_ok) success path. The variable is cleared
##        ('') on warn / dry-run paths so callers can rely on "empty
##        means no body to parse".
##
## Returns: 0 on success (or dry-run), 1 on failure.
policy_api_call() {
   local label method path body extra_ok body_var_name
   local result status response_body

   label="$1"
   method="$2"
   path="$3"
   body="${4:-}"
   ## Sentinel no real HTTP code can equal so the interpolated case
   ## pattern stays well-formed when no extras were passed.
   extra_ok="${5:-__none__}"
   body_var_name="${6:-}"
   ## Non-empty must be a legal bash identifier (flows into 'printf -v').
   [ -z "${body_var_name}" ] || check_variable_name "${body_var_name}" || return 1

   if [ "${dry_run}" = '1' ]; then
      log notice "DRY-RUN: ${label} -> ${method} ${path}"
      [ -n "${body_var_name}" ] && printf -v "${body_var_name}" '%s' ''
      return 0
   fi
   result="$(ghorg_api "${method}" "${path}" "${body}")" || {
      [ -n "${body_var_name}" ] && printf -v "${body_var_name}" '%s' ''
      policy_warn_seen=1
      return 1
   }
   status="$(ghorg_status_of "${result}")"
   case "${status}" in
      200|201|202|204|"${extra_ok}")
         if [ -n "${body_var_name}" ]; then
            response_body="$(ghorg_body_of "${result}")"
            printf -v "${body_var_name}" '%s' "${response_body}"
         fi
         [ "${POLICY_QUIET_OK:-0}" = '1' ] \
            || log notice "ok: ${label} [${status}]"
         return 0
         ;;
      *)
         response_body="$(ghorg_body_of "${result}")"
         [ -n "${body_var_name}" ] && printf -v "${body_var_name}" '%s' ''
         log warn "'${label}' ['${status}']: ${response_body:0:512}"
         policy_warn_seen=1
         return 1
         ;;
   esac
}

## ============================================================================
## Ruleset upsert
## ============================================================================

## Shared find-or-create for org-level and repo-level rulesets. Use
## policy_upsert_org_ruleset / policy_upsert_repo_ruleset rather than
## this directly.
##
## Args:
##   $1 - listing endpoint
##   $2 - human-readable scope label
##   $3 - ruleset name (must match the .name field in the body)
##   $4 - JSON body
_policy_upsert_ruleset() {
   local list_endpoint scope_label name body
   local list_body existing_id

   list_endpoint="$1"
   scope_label="$2"
   name="$3"
   body="$4"

   if [ "${dry_run}" = '1' ]; then
      ## Format matches what ci/tests/test_dm_dryrun.sh greps for as a
      ## fixed string ("DRY-RUN: ${scope}: upsert ruleset ${name}",
      ## no quotes around the name). Don't add quoting here.
      log notice "DRY-RUN: ${scope_label}: upsert ruleset ${name}"
      return 0
   fi

   policy_api_call "${scope_label}: list rulesets" \
      GET "${list_endpoint}?per_page=100" '' '' list_body \
      || return 1

   ## Hostile-input path: ghorg_jq_capped caps stdin to
   ## GHORG_JQ_MAX_BYTES before jq sees it.
   existing_id="$(printf '%s' "${list_body}" \
      | ghorg_jq_capped -r --arg name "${name}" -- \
        '.[] | select(.name == $name) | .id' \
      | head --lines=1)"

   if [ -n "${existing_id}" ] && [ "${existing_id}" != 'null' ]; then
      ## API-derived id flows into a URL path.
      if ! [[ "${existing_id}" =~ ^[0-9]+$ ]] \
         || [ "${#existing_id}" -gt "${GHORG_MAX_ID_LEN}" ]; then
         log warn "'${scope_label}': ruleset id '${existing_id}' not a valid numeric id"
         policy_warn_seen=1
         return 1
      fi
      policy_api_call "${scope_label}: update ruleset '${name}' id=${existing_id}" \
         PUT "${list_endpoint}/${existing_id}" "${body}"
   else
      policy_api_call "${scope_label}: create ruleset '${name}'" \
         POST "${list_endpoint}" "${body}"
   fi
}

## Args: $1 = org, $2 = ruleset name, $3 = JSON body.
policy_upsert_org_ruleset() {
   local org name body

   org="$1"
   name="$2"
   body="$3"

   _policy_upsert_ruleset "/orgs/${org}/rulesets" "${org}" "${name}" "${body}"
}

## Args: $1 = owner, $2 = repo, $3 = ruleset name, $4 = JSON body.
policy_upsert_repo_ruleset() {
   local owner repo name body

   owner="$1"
   repo="$2"
   name="$3"
   body="$4"

   _policy_upsert_ruleset \
      "/repos/${owner}/${repo}/rulesets" \
      "${owner}/${repo}" \
      "${name}" \
      "${body}"
}

## ============================================================================
## Policy dispatchers
## ============================================================================
##
## Resolve a policy by NAME (suffixed POLICY_<NAME>_BODY,
## POLICY_<NAME>_ENDPOINT_*, POLICY_<NAME>_METHOD, POLICY_<NAME>_LABEL
## constants in github-policy-data.bsh) and hand its parts to
## policy_api_call. The naming scheme is the interface; renaming a
## constant means renaming both ends.

## policy_apply_org <org> <NAME> [<endpoint_suffix=ENDPOINT_ORG>]
## Substitutes __ORG__ in the endpoint. A POLICY_<NAME>_EXTRA_OK_STATUS
## constant is forwarded as policy_api_call's 5th arg (G-035).
policy_apply_org() {
   local org name endpoint_suffix
   local body_v endpoint_v method_v label_v extra_ok_v endpoint

   org="$1"
   name="$2"
   endpoint_suffix="${3:-ENDPOINT_ORG}"

   body_v="POLICY_${name}"
   endpoint_v="POLICY_${name}_${endpoint_suffix}"
   method_v="POLICY_${name}_METHOD"
   label_v="POLICY_${name}_LABEL"
   extra_ok_v="POLICY_${name}_EXTRA_OK_STATUS"

   check_variable_name "${body_v}" || return 1
   check_variable_name "${endpoint_v}" || return 1
   check_variable_name "${method_v}" || return 1
   check_variable_name "${label_v}" || return 1
   check_variable_name "${extra_ok_v}" || return 1

   endpoint="${!endpoint_v}"
   endpoint="${endpoint//__ORG__/${org}}"

   policy_api_call "${org}: ${!label_v}" \
      "${!method_v}" "${endpoint}" \
      "$(ghorg_jq -n -- "${!body_v}")" \
      "${!extra_ok_v:-}" \
      || true
}

## policy_apply_repo <owner> <repo> <NAME> [<endpoint_suffix=ENDPOINT_REPO>]
## Substitutes __OWNER__ and __REPO__. Honors POLICY_<NAME>_EXTRA_OK_STATUS
## the same way policy_apply_org does.
policy_apply_repo() {
   local owner repo name endpoint_suffix
   local body_v endpoint_v method_v label_v extra_ok_v endpoint

   owner="$1"
   repo="$2"
   name="$3"
   endpoint_suffix="${4:-ENDPOINT_REPO}"

   body_v="POLICY_${name}"
   endpoint_v="POLICY_${name}_${endpoint_suffix}"
   method_v="POLICY_${name}_METHOD"
   label_v="POLICY_${name}_LABEL"
   extra_ok_v="POLICY_${name}_EXTRA_OK_STATUS"

   check_variable_name "${body_v}" || return 1
   check_variable_name "${endpoint_v}" || return 1
   check_variable_name "${method_v}" || return 1
   check_variable_name "${label_v}" || return 1
   check_variable_name "${extra_ok_v}" || return 1

   endpoint="${!endpoint_v}"
   endpoint="${endpoint//__OWNER__/${owner}}"
   endpoint="${endpoint//__REPO__/${repo}}"

   policy_api_call "${owner}/${repo}: ${!label_v}" \
      "${!method_v}" "${endpoint}" \
      "$(ghorg_jq -n -- "${!body_v}")" \
      "${!extra_ok_v:-}" \
      || true
}

## policy_skip_ui <org_or_label_context> <NAME> <ui_url_var_suffix>
## For UI-only policies; emits a 'skip:' log line with the resolved
## UI URL. The URL constant is POLICY_<NAME>_UI_URL by default; pass
## a different suffix to address one of several URLs registered for
## the same logical policy.
policy_skip_ui() {
   local context name suffix label_v url_v url

   context="$1"
   name="$2"
   suffix="${3:-UI_URL}"

   label_v="POLICY_${name}_LABEL"
   url_v="POLICY_${name}_${suffix}"

   check_variable_name "${label_v}" || return 1
   check_variable_name "${url_v}" || return 1

   url="${!url_v}"
   url="${url//__ORG__/${context}}"

   log notice "skip: ${context}: ${!label_v} - see ${url}"
}

## Per-repo variant of policy_skip_ui. Substitutes __OWNER__ and
## __REPO__ in the URL constant; default suffix UI_URL_REPO to mirror
## the existing ENDPOINT_REPO naming on per-repo API constants.
policy_skip_ui_repo() {
   local owner repo name suffix label_v url_v url

   owner="$1"
   repo="$2"
   name="$3"
   suffix="${4:-UI_URL_REPO}"

   label_v="POLICY_${name}_LABEL"
   url_v="POLICY_${name}_${suffix}"

   check_variable_name "${label_v}" || return 1
   check_variable_name "${url_v}" || return 1

   url="${!url_v}"
   url="${url//__OWNER__/${owner}}"
   url="${url//__REPO__/${repo}}"

   log notice "skip: ${owner}/${repo}: ${!label_v} - see ${url}"
}

## ============================================================================
## JSON body factories
## ============================================================================

## Branch ruleset body. Body shape lives in github-policy-data.bsh.
##
## Args:
##   $1 - ruleset name
##   $2 - 'org' or 'repo'
##   $3 - bypass_actors JSON array (default '[]')
##   $4 - rules JSON array (default POLICY_RULESET_RULES_SOURCE - the
##        with-required_signatures variant; callers on roles that
##        omit required_signatures pass POLICY_RULESET_RULES_MIRROR
##        or POLICY_RULESET_RULES_BOT here).
policy_branch_ruleset_body() {
   local name scope bypass rules body

   name="$1"
   scope="${2:-repo}"
   bypass="${3:-[]}"
   rules="${4:-${POLICY_RULESET_RULES_SOURCE}}"

   ## PAID PLAN ONLY: scope=org needs POLICY_BRANCH_RULESET_BODY_ORG,
   ## commented out in github-policy-data.bsh because
   ## /orgs/{org}/rulesets is Team+ only. Re-enable both together.
   # if [ "${scope}" = 'org' ]; then
   #    body="${POLICY_BRANCH_RULESET_BODY_ORG}"
   # else
   #    body="${POLICY_BRANCH_RULESET_BODY_REPO}"
   # fi
   if [ "${scope}" = 'org' ]; then
      log error "policy_branch_ruleset_body: scope=org is PAID PLAN ONLY (GitHub Team+); use scope=repo on Free"
      return 1
   fi
   body="${POLICY_BRANCH_RULESET_BODY_REPO}"

   ghorg_jq -n \
      --arg name "${name}" \
      --argjson bypass "${bypass}" \
      --argjson rules "${rules}" \
      -- "${body}"
}

## Tag ruleset body. Args: same as policy_branch_ruleset_body.
policy_tag_ruleset_body() {
   local name scope bypass rules body

   name="$1"
   scope="${2:-repo}"
   bypass="${3:-[]}"
   rules="${4:-${POLICY_RULESET_RULES_SOURCE}}"

   ## PAID PLAN ONLY: see policy_branch_ruleset_body note above.
   # if [ "${scope}" = 'org' ]; then
   #    body="${POLICY_TAG_RULESET_BODY_ORG}"
   # else
   #    body="${POLICY_TAG_RULESET_BODY_REPO}"
   # fi
   if [ "${scope}" = 'org' ]; then
      log error "policy_tag_ruleset_body: scope=org is PAID PLAN ONLY (GitHub Team+); use scope=repo on Free"
      return 1
   fi
   body="${POLICY_TAG_RULESET_BODY_REPO}"

   ghorg_jq -n \
      --arg name "${name}" \
      --argjson bypass "${bypass}" \
      --argjson rules "${rules}" \
      -- "${body}"
}

## ============================================================================
## Argument parser
## ============================================================================

## Shared --apply / --dry-run / --audit / -v|--verbose / -h|--help
## parser. Strict mutual-exclusion on the mode flags.
##
## Sets in CALLER scope (bash dynamic scoping):
##
##   mode='apply'|'audit'
##   dry_run=0|1
##   verbose=0|1
##   mode_set=0|1
##   positional+=( ... )    non-flag args (and everything after `--`)
##
## Caller must declare each above with sane defaults
## (mode='', dry_run=0, verbose=0, mode_set=0, positional=()),
## define a 'show_help' function, and perform the post-parse
## "[ ${mode_set} -eq 1 ]" plus per-tool positional-count check.
##
## Args: the script's "$@" verbatim.
policy_parse_mode_args() {
   while [ "$#" -gt 0 ]; do
      case "$1" in
         --apply)
            [ "${mode_set}" -eq 0 ] \
               || die 64 'conflicting mode flags; specify exactly one of --apply / --audit / --dry-run'
            mode='apply'
            mode_set=1
            shift
            ;;
         --dry-run)
            [ "${mode_set}" -eq 0 ] \
               || die 64 'conflicting mode flags; specify exactly one of --apply / --audit / --dry-run'
            mode='apply'
            dry_run=1
            mode_set=1
            shift
            ;;
         --audit)
            [ "${mode_set}" -eq 0 ] \
               || die 64 'conflicting mode flags; specify exactly one of --apply / --audit / --dry-run'
            mode='audit'
            mode_set=1
            shift
            ;;
         -v|--verbose)
            verbose=1
            shift
            ;;
         -h|--help)
            show_help
            exit 0
            ;;
         --policy-dump)
            ## Print github-policy-data.bsh verbatim. Resolved relative
            ## to this lib's path (BASH_SOURCE[0]) so dev-tree runs
            ## also work, not just /usr/libexec/... installs.
            cat -- "$(dirname -- "${BASH_SOURCE[0]}")/github-policy-data.bsh"
            exit 0
            ;;
         --)
            shift
            while [ "$#" -gt 0 ]; do
               positional+=( "$1" )
               shift
            done
            ;;
         -*)
            die 64 "unknown option: '$1'"
            ;;
         *)
            positional+=( "$1" )
            shift
            ;;
      esac
   done
}

## Tool-init wrapper around policy_parse_mode_args. Parse the flags,
## post-check positional count + mode flag, run ghorg_require_deps,
## zero the warn-flag.
##
## Caller declares 'mode='', dry_run=0, verbose=0, mode_set=0,
## positional=()' and defines a 'show_help' function.
##
## Args: $1 expected positional count, $2... "$@" verbatim.
policy_tool_init() {
   local expected_positional
   expected_positional="$1"
   shift

   policy_parse_mode_args "$@"

   [ "${#positional[@]}" -eq "${expected_positional}" ] \
      || { show_help >&2; die 64 "expected exactly '${expected_positional}' positional argument(s), got '${#positional[@]}'"; }

   [ "${mode_set}" -eq 1 ] \
      || { show_help >&2; die 64 'specify exactly one of --apply / --audit / --dry-run'; }

   ghorg_require_deps

   policy_warn_seen=0
}
