#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
#
# gh-wrapper-guard.sh — static checker for raw gh issue/pr create AND edit calls
#
# Enforces two rules from AGENTS.md:
#   1. Origin labelling: "NEVER use raw gh pr create or gh issue create directly.
#      Always use the wrappers: gh_create_pr and gh_create_issue."
#   2. Edit safety (GH#19857): "NEVER use raw gh issue edit / gh pr edit with
#      --title or --body. Always use gh_issue_edit_safe / gh_pr_edit_safe."
#
# Subcommands:
#   check        --base <ref>   Scan added/modified lines in diff vs <ref>
#   check-staged                Scan staged changes (git diff --cached)
#   check-full                  Scan all tracked .sh files in the repo
#
# Exit codes:
#   0  — clean (no violations)
#   1  — violations found
#   2  — usage error
#
# Line-level allowlist:
#   Any line ending with "# aidevops-allow: raw-gh-wrapper" is skipped.
#
# File-level exclusions:
#   - shared-constants.sh (definition site for the wrappers)
#   - shared-gh-wrappers.sh (extracted sub-library sourced from shared-constants.sh)
#   - .agents/scripts/tests/** (test fixtures may legitimately contain raw calls)
#
# Environment:
#   GH_WRAPPER_GUARD_DISABLE=1 — bypass entirely (exit 0)

set -euo pipefail

# --- bypass ----------------------------------------------------------------
if [[ "${GH_WRAPPER_GUARD_DISABLE:-0}" == "1" ]]; then
	exit 0
fi

# --- constants --------------------------------------------------------------
# Patterns that match raw gh issue/pr create invocations.
# We match:  gh issue create  /  gh pr create
# We skip lines containing the allowlist marker.
readonly ALLOWLIST_MARKER='# aidevops-allow: raw-gh-wrapper'

# File-level exclusions (basename or path-suffix match)
_is_excluded_file() {
	local file="$1"
	# Definition sites — shared-gh-wrappers.sh is the extracted sub-library
	# that contains the actual wrapper implementations (gh_create_issue, etc.)
	# sourced from shared-constants.sh. Both must be excluded because they
	# contain intentional raw gh calls inside the wrapper function bodies.
	case "$file" in
	*shared-constants.sh) return 0 ;;
	*shared-gh-wrappers.sh) return 0 ;;
	*agents/scripts/tests/*) return 0 ;;
	esac
	return 1
}

# --- helpers -----------------------------------------------------------------

_scan_line() {
	# Returns 0 if the line contains a raw gh issue/pr create call that is
	# NOT allowlisted. Returns 1 otherwise (clean).
	local line="$1"

	# Skip allowlisted lines
	if [[ "$line" == *"$ALLOWLIST_MARKER"* ]]; then
		return 1
	fi

	# Skip comment-only lines (leading # after optional whitespace)
	local stripped="${line#"${line%%[![:space:]]*}"}"
	if [[ "$stripped" == \#* ]]; then
		return 1
	fi

	# Match raw "gh issue create" or "gh pr create"
	# Use word-boundary-like matching: the gh command should be preceded by
	# start-of-line, whitespace, pipe, semicolon, $( or backtick.
	if printf '%s\n' "$line" | grep -qE '(^|[[:space:];|`]|\$\()gh[[:space:]]+issue[[:space:]]+create'; then
		return 0
	fi
	if printf '%s\n' "$line" | grep -qE '(^|[[:space:];|`]|\$\()gh[[:space:]]+pr[[:space:]]+create'; then
		return 0
	fi

	# GH#19857: Match raw "gh issue edit" or "gh pr edit" with --title or --body.
	# Label-only edits (no --title/--body) are fine without the safe wrapper.
	if printf '%s\n' "$line" | grep -qE '(^|[[:space:];|`]|\$\()gh[[:space:]]+issue[[:space:]]+edit' &&
		printf '%s\n' "$line" | grep -qE '\-\-title\b|\-\-body\b|\-\-body-file\b'; then
		return 0
	fi
	if printf '%s\n' "$line" | grep -qE '(^|[[:space:];|`]|\$\()gh[[:space:]]+pr[[:space:]]+edit' &&
		printf '%s\n' "$line" | grep -qE '\-\-title\b|\-\-body\b|\-\-body-file\b'; then
		return 0
	fi

	return 1
}

_format_violation() {
	local file="$1"
	local lineno="$2"
	local line="$3"
	printf '  %s:%s: %s\n' "$file" "$lineno" "$line"
}

# --- subcommands -------------------------------------------------------------

# _scan_diff_output scans a unified diff for added lines containing violations.
# Sets _SCAN_VIOLATIONS and _SCAN_OUTPUT (appended, not reset).
_SCAN_VIOLATIONS=0
_SCAN_OUTPUT=""

_scan_diff_for_file() {
	local file="$1"
	local diff_output="$2"
	local current_lineno=0

	while IFS= read -r diff_line; do
		# Track line numbers from @@ hunks
		if [[ "$diff_line" =~ ^@@.*\+([0-9]+) ]]; then
			current_lineno=$((BASH_REMATCH[1] - 1))
			continue
		fi

		# Only look at added lines
		if [[ "$diff_line" == +* && "$diff_line" != "+++"* ]]; then
			current_lineno=$((current_lineno + 1))
			local content="${diff_line:1}"
			if _scan_line "$content"; then
				_SCAN_OUTPUT+=$(_format_violation "$file" "$current_lineno" "$content")
				_SCAN_OUTPUT+=$'\n'
				_SCAN_VIOLATIONS=$((_SCAN_VIOLATIONS + 1))
			fi
		elif [[ "$diff_line" != -* && "$diff_line" != "---"* ]]; then
			current_lineno=$((current_lineno + 1))
		fi
	done <<<"$diff_output"
	return 0
}

_report_violations() {
	if [[ "$_SCAN_VIOLATIONS" -gt 0 ]]; then
		printf 'gh-wrapper-guard: %d violation(s) found — use safe wrappers instead of raw gh commands.\n' "$_SCAN_VIOLATIONS"
		printf 'Create: gh_create_issue / gh_create_pr. Edit: gh_issue_edit_safe / gh_pr_edit_safe.\n'
		printf 'Rule: AGENTS.md → "Origin labelling" + GH#19857 (edit safety invariant)\n'
		printf 'Suppress: append "# aidevops-allow: raw-gh-wrapper" to the line.\n\n'
		printf '%s' "$_SCAN_OUTPUT"
		return 1
	fi
	return 0
}

cmd_check() {
	local base_ref="${1:-}"
	if [[ -z "$base_ref" ]]; then
		printf 'Usage: gh-wrapper-guard.sh check --base <ref>\n' >&2
		return 2
	fi

	_SCAN_VIOLATIONS=0
	_SCAN_OUTPUT=""

	# Get list of .sh files changed relative to base
	local changed_files
	changed_files=$(git diff --name-only "$base_ref"...HEAD -- '*.sh' 2>/dev/null || true)
	if [[ -z "$changed_files" ]]; then
		return 0
	fi

	while IFS= read -r file; do
		[[ -z "$file" ]] && continue
		_is_excluded_file "$file" && continue
		[[ -f "$file" ]] || continue

		local diff_output
		diff_output=$(git diff "$base_ref"...HEAD -- "$file" 2>/dev/null || true)
		[[ -z "$diff_output" ]] && continue

		_scan_diff_for_file "$file" "$diff_output"
	done <<<"$changed_files"

	_report_violations
	return $?
}

cmd_check_staged() {
	_SCAN_VIOLATIONS=0
	_SCAN_OUTPUT=""

	# Get staged .sh files
	local staged_files
	staged_files=$(git diff --cached --name-only -- '*.sh' 2>/dev/null || true)
	if [[ -z "$staged_files" ]]; then
		return 0
	fi

	while IFS= read -r file; do
		[[ -z "$file" ]] && continue
		_is_excluded_file "$file" && continue

		local diff_output
		diff_output=$(git diff --cached -- "$file" 2>/dev/null || true)
		[[ -z "$diff_output" ]] && continue

		_scan_diff_for_file "$file" "$diff_output"
	done <<<"$staged_files"

	_report_violations
	return $?
}

cmd_check_full() {
	local violations=0
	local violation_output=""

	# Scan all tracked .sh files using grep for speed (661+ files).
	# Exclude shared-constants.sh and tests/ at the grep level (not bash loop).
	local file_count
	file_count=$(git ls-files '*.sh' 2>/dev/null | grep -cvE '(shared-constants\.sh|agents/scripts/tests/)' || true)
	[[ "$file_count" -eq 0 ]] && return 0

	# Fast grep pass: find candidate files with raw gh issue/pr create or edit
	local candidates
	candidates=$(git ls-files -z '*.sh' 2>/dev/null |
		grep -zvE '(shared-constants\.sh|agents/scripts/tests/)' |
		xargs -0 grep -lE 'gh[[:space:]]+(issue|pr)[[:space:]]+(create|edit)' ||
		true)

	if [[ -z "$candidates" ]]; then
		printf 'gh-wrapper-guard: all %s .sh files clean.\n' "$file_count"
		return 0
	fi

	# Detailed scan of candidate files only (verify line-level: skip comments + allowlist)
	while IFS= read -r file; do
		[[ -z "$file" ]] && continue
		[[ -f "$file" ]] || continue
		# Use grep -n for fast line+number extraction, then filter
		while IFS=: read -r lineno line; do
			if _scan_line "$line"; then
				violation_output+=$(_format_violation "$file" "$lineno" "$line")
				violation_output+=$'\n'
				violations=$((violations + 1))
			fi
		done < <(grep -nE 'gh[[:space:]]+(issue|pr)[[:space:]]+(create|edit)' "$file" 2>/dev/null || true)
	done <<<"$candidates"

	if [[ "$violations" -gt 0 ]]; then
		printf 'gh-wrapper-guard: %d violation(s) found — use safe wrappers instead of raw gh commands.\n' "$violations"
		printf 'Create: gh_create_issue / gh_create_pr. Edit: gh_issue_edit_safe / gh_pr_edit_safe.\n'
		printf 'Rule: AGENTS.md → "Origin labelling" + GH#19857 (edit safety invariant)\n'
		printf 'Suppress: append "# aidevops-allow: raw-gh-wrapper" to the line.\n\n'
		printf '%s' "$violation_output"
		return 1
	fi

	printf 'gh-wrapper-guard: all %s .sh files clean.\n' "$file_count"
	return 0
}

show_help() {
	printf 'gh-wrapper-guard.sh — enforce safe gh wrapper usage (create + edit)\n\n'
	printf 'Usage:\n'
	printf '  gh-wrapper-guard.sh check --base <ref>   Scan PR diff vs base ref\n'
	printf '  gh-wrapper-guard.sh check-staged          Scan staged changes\n'
	printf '  gh-wrapper-guard.sh check-full             Scan all tracked .sh files\n'
	printf '  gh-wrapper-guard.sh help                   Show this help\n\n'
	printf 'Environment:\n'
	printf '  GH_WRAPPER_GUARD_DISABLE=1  Bypass entirely\n\n'
	printf 'Line suppression:\n'
	printf '  Append "# aidevops-allow: raw-gh-wrapper" to any line to skip it.\n'
	return 0
}

# --- argument parsing --------------------------------------------------------
_parse_base_arg() {
	# Extracts --base <ref> or bare <ref> from args. Prints the ref.
	while [[ $# -gt 0 ]]; do
		case "$1" in
		--base)
			echo "${2:-}"
			return 0
			;;
		*)
			echo "$1"
			return 0
			;;
		esac
	done
	return 0
}

# --- main --------------------------------------------------------------------
main() {
	local cmd="${1:-help}"
	shift || true

	case "$cmd" in
	check) cmd_check "$(_parse_base_arg "$@")" ;;
	check-staged) cmd_check_staged ;;
	check-full) cmd_check_full ;;
	help | --help | -h) show_help ;;
	*)
		printf 'Unknown command: %s\n' "$cmd" >&2
		show_help
		return 2
		;;
	esac
}

main "$@"
