#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
# test-review-bot-gate-completion-signal.sh — Regression tests for t2139 (GH#19251)
#
# Verifies that bot_has_real_review() requires a positive completion signal
# and rejects:
#   1. CodeRabbit two-phase placeholder (created_at == updated_at, recent)
#   2. "Review failed" notices (closed-during-review)
#   3. "Review skipped" notices (auto-review label config)
#   4. "closed or merged during review" patterns
#   5. Empty bodies
#
# And accepts:
#   6. Comments edited > min_lag after creation (Phase 2 settled)
#   7. Comments older than min_lag (no edit needed — bot had time to finish)
#   8. Real review content with no non-review-pattern match
#
# Plus unit tests on _comment_is_settled, is_non_review_comment, and
# _get_min_edit_lag direct invocations (no gh stubbing needed).
#
# Requires: bash, jq (matches helper requirements), date.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit 2
HELPER_SCRIPT="${SCRIPT_DIR}/../review-bot-gate-helper.sh"

if [[ ! -f "$HELPER_SCRIPT" ]]; then
	echo "ERROR: helper not found at ${HELPER_SCRIPT}" >&2
	exit 2
fi

readonly TEST_RED='\033[0;31m'
readonly TEST_GREEN='\033[0;32m'
readonly TEST_RESET='\033[0m'

TESTS_RUN=0
TESTS_FAILED=0
TEST_ROOT=""

print_result() {
	local test_name="$1"
	local passed="$2"
	local message="${3:-}"
	TESTS_RUN=$((TESTS_RUN + 1))

	if [[ "$passed" -eq 0 ]]; then
		printf '%bPASS%b %s\n' "$TEST_GREEN" "$TEST_RESET" "$test_name"
		return 0
	fi

	printf '%bFAIL%b %s\n' "$TEST_RED" "$TEST_RESET" "$test_name"
	if [[ -n "$message" ]]; then
		printf '       %s\n' "$message"
	fi
	TESTS_FAILED=$((TESTS_FAILED + 1))
	return 0
}

setup_test_env() {
	TEST_ROOT=$(mktemp -d)
	mkdir -p "${TEST_ROOT}/bin"
	mkdir -p "${TEST_ROOT}/config/aidevops"
	export PATH="${TEST_ROOT}/bin:${PATH}"
	export HOME="${TEST_ROOT}"
	mkdir -p "${TEST_ROOT}/.config/aidevops"

	# Minimal repos.json for resolver tests
	cat >"${TEST_ROOT}/.config/aidevops/repos.json" <<'EOF'
{
  "initialized_repos": [
    {
      "path": "/tmp/testrepo",
      "slug": "testorg/testrepo",
      "pulse": true,
      "review_gate": {
        "min_edit_lag_seconds": 45,
        "tools": {
          "coderabbitai": { "min_edit_lag_seconds": 90 }
        }
      }
    },
    {
      "path": "/tmp/otherrepo",
      "slug": "testorg/otherrepo",
      "pulse": true
    },
    {
      "path": "/tmp/strictrepo",
      "slug": "testorg/strictrepo",
      "pulse": true,
      "review_gate": {
        "completion_behavior": "strict"
      }
    }
  ]
}
EOF
	return 0
}

cleanup_test_env() {
	if [[ -n "$TEST_ROOT" && -d "$TEST_ROOT" ]]; then
		rm -rf "$TEST_ROOT"
	fi
	return 0
}

# Run a function from the helper without invoking main(). Source it with
# main() short-circuited.
load_helper_functions() {
	# shellcheck disable=SC1090
	# Trick: source the helper but make main() a no-op so it doesn't error.
	# We strip the trailing 'main "$@"' invocation by sourcing in a subshell
	# alternative — actually, the helper has `main "$@"` at the end which
	# would execute. We override main BEFORE sourcing? No — sourcing runs
	# top-level immediately. Solution: source via a temp file with the last
	# `main "$@"` line removed.
	local tmpfile
	tmpfile=$(mktemp)
	# Drop the final main invocation line using grep for robustness.
	grep -v '^main "\$@"' "$HELPER_SCRIPT" >"$tmpfile"
	# shellcheck disable=SC1090
	source "$tmpfile"
	rm -f "$tmpfile"
	return 0
}

# ---------- Unit tests: pattern matching ----------

test_is_non_review_comment_matches_rate_limit() {
	if is_non_review_comment "Review skipped due to rate limit exceeded for this hour"; then
		print_result "is_non_review_comment matches 'rate limit exceeded'" 0
	else
		print_result "is_non_review_comment matches 'rate limit exceeded'" 1
	fi
	return 0
}

test_is_non_review_comment_matches_review_failed() {
	if is_non_review_comment "Review failed — Pull request was closed or merged during review."; then
		print_result "is_non_review_comment matches 'Review failed'" 0
	else
		print_result "is_non_review_comment matches 'Review failed'" 1
	fi
	return 0
}

test_is_non_review_comment_matches_review_skipped() {
	if is_non_review_comment "Review skipped — Auto reviews are limited based on label configuration."; then
		print_result "is_non_review_comment matches 'Review skipped'" 0
	else
		print_result "is_non_review_comment matches 'Review skipped'" 1
	fi
	return 0
}

test_is_non_review_comment_matches_closed_during_review() {
	if is_non_review_comment "The pull request is closed or merged during review."; then
		print_result "is_non_review_comment matches 'closed or merged during review'" 0
	else
		print_result "is_non_review_comment matches 'closed or merged during review'" 1
	fi
	return 0
}

test_is_non_review_comment_rejects_real_review() {
	local body="## Walkthrough

This PR refactors the foo() function to handle the bar edge case.
Recommended changes: add a null check before line 42."
	if ! is_non_review_comment "$body"; then
		print_result "is_non_review_comment rejects real review body" 0
	else
		print_result "is_non_review_comment rejects real review body" 1
	fi
	return 0
}

# t2799: is_rate_limit_only_comment matches the narrow 6-entry rate-limit set
# only — NOT the broader non-review patterns ("Review failed", "Review
# skipped", etc.). Used by grace-period logic where the semantic distinction
# matters: a rate-limited bot may recover, a "Review skipped" bot will not.
test_is_rate_limit_only_matches_rate_limit() {
	if is_rate_limit_only_comment "Review skipped due to rate limit exceeded for this hour"; then
		print_result "is_rate_limit_only_comment matches 'rate limit exceeded'" 0
	else
		print_result "is_rate_limit_only_comment matches 'rate limit exceeded'" 1
	fi
	return 0
}

test_is_rate_limit_only_rejects_review_failed() {
	# "Review failed" is in NON_REVIEW_PATTERNS but NOT RATE_LIMIT_PATTERNS —
	# this is the whole point of the t2799 split.
	if ! is_rate_limit_only_comment "Review failed — Pull request was closed or merged during review."; then
		print_result "is_rate_limit_only_comment rejects 'Review failed'" 0
	else
		print_result "is_rate_limit_only_comment rejects 'Review failed'" 1
	fi
	return 0
}

test_is_rate_limit_only_rejects_review_skipped() {
	# Same: "Review skipped" / "Auto reviews are limited" are non-review but
	# not rate-limit. Misclassifying these as rate-limited would trigger the
	# grace-period retry path inappropriately.
	if ! is_rate_limit_only_comment "Review skipped — Auto reviews are limited based on label configuration."; then
		print_result "is_rate_limit_only_comment rejects 'Review skipped'" 0
	else
		print_result "is_rate_limit_only_comment rejects 'Review skipped'" 1
	fi
	return 0
}

test_is_rate_limit_only_rejects_real_review() {
	local body="## Walkthrough

This PR refactors the foo() function to handle the bar edge case."
	if ! is_rate_limit_only_comment "$body"; then
		print_result "is_rate_limit_only_comment rejects real review body" 0
	else
		print_result "is_rate_limit_only_comment rejects real review body" 1
	fi
	return 0
}

# ---------- Unit tests: settled-check ----------

test_settled_recent_unedited_placeholder_rejected() {
	# Created 5s ago, never edited, min_lag=30 → NOT settled (placeholder window).
	local now created updated
	now=$(date +%s)
	created=$(TZ=UTC date -u -r "$((now - 5))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 5))" +"%Y-%m-%dT%H:%M:%SZ")
	updated="$created"
	if ! _comment_is_settled "$created" "$updated" 30; then
		print_result "settled rejects recent unedited placeholder" 0
	else
		print_result "settled rejects recent unedited placeholder" 1 \
			"created=${created} updated=${updated} now=${now}"
	fi
	return 0
}

test_settled_old_unedited_accepted() {
	# Created 120s ago, never edited, min_lag=30 → settled by age.
	local now created
	now=$(date +%s)
	created=$(TZ=UTC date -u -r "$((now - 120))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 120))" +"%Y-%m-%dT%H:%M:%SZ")
	if _comment_is_settled "$created" "$created" 30; then
		print_result "settled accepts old unedited comment" 0
	else
		print_result "settled accepts old unedited comment" 1
	fi
	return 0
}

test_settled_recent_edited_accepted() {
	# Created 10s ago, edited 5s ago (delta = 5s < min_lag of 30) → not settled
	# by edit AND age 10s < min_lag 30 → NOT settled.
	local now created updated
	now=$(date +%s)
	created=$(TZ=UTC date -u -r "$((now - 10))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 10))" +"%Y-%m-%dT%H:%M:%SZ")
	updated=$(TZ=UTC date -u -r "$((now - 5))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 5))" +"%Y-%m-%dT%H:%M:%SZ")
	if ! _comment_is_settled "$created" "$updated" 30; then
		print_result "settled rejects edit-delta and age both under min_lag" 0
	else
		print_result "settled rejects edit-delta and age both under min_lag" 1
	fi
	return 0
}

test_settled_edit_delta_over_min_lag_accepted() {
	# Created 100s ago, edited 5s ago → edit_delta = 95s >= min_lag 30 → settled.
	local now created updated
	now=$(date +%s)
	created=$(TZ=UTC date -u -r "$((now - 100))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 100))" +"%Y-%m-%dT%H:%M:%SZ")
	updated=$(TZ=UTC date -u -r "$((now - 5))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 5))" +"%Y-%m-%dT%H:%M:%SZ")
	if _comment_is_settled "$created" "$updated" 30; then
		print_result "settled accepts when edit_delta >= min_lag" 0
	else
		print_result "settled accepts when edit_delta >= min_lag" 1
	fi
	return 0
}

test_settled_missing_timestamps_conservative_pass() {
	# Empty created_at → conservative pass (don't block on missing data).
	if _comment_is_settled "" "" 30; then
		print_result "settled passes conservatively on missing created_at" 0
	else
		print_result "settled passes conservatively on missing created_at" 1
	fi
	return 0
}

test_settled_unparseable_timestamps_conservative_pass() {
	# Garbage timestamp → epoch=0 → conservative pass.
	if _comment_is_settled "not-a-date" "also-not-a-date" 30; then
		print_result "settled passes conservatively on unparseable timestamps" 0
	else
		print_result "settled passes conservatively on unparseable timestamps" 1
	fi
	return 0
}

# ---------- Unit tests: per-tool / per-repo lag resolution ----------

test_get_min_edit_lag_per_tool() {
	# coderabbitai on testorg/testrepo → 90 (per-tool override)
	local lag
	lag=$(_get_min_edit_lag "testorg/testrepo" "coderabbitai")
	if [[ "$lag" == "90" ]]; then
		print_result "get_min_edit_lag returns per-tool override (90)" 0
	else
		print_result "get_min_edit_lag returns per-tool override (90)" 1 \
			"got '${lag}', expected '90'"
	fi
	return 0
}

test_get_min_edit_lag_per_repo() {
	# gemini-code-assist on testorg/testrepo → 45 (per-repo default, no per-tool)
	local lag
	lag=$(_get_min_edit_lag "testorg/testrepo" "gemini-code-assist")
	if [[ "$lag" == "45" ]]; then
		print_result "get_min_edit_lag falls back to per-repo default (45)" 0
	else
		print_result "get_min_edit_lag falls back to per-repo default (45)" 1 \
			"got '${lag}', expected '45'"
	fi
	return 0
}

test_get_min_edit_lag_global_default() {
	# any bot on testorg/otherrepo → REVIEW_BOT_MIN_EDIT_LAG_SECONDS (30)
	local lag
	lag=$(_get_min_edit_lag "testorg/otherrepo" "coderabbitai")
	if [[ "$lag" == "30" ]]; then
		print_result "get_min_edit_lag falls back to global default (30)" 0
	else
		print_result "get_min_edit_lag falls back to global default (30)" 1 \
			"got '${lag}', expected '30'"
	fi
	return 0
}

test_get_min_edit_lag_unknown_repo_default() {
	# unknown repo → global default
	local lag
	lag=$(_get_min_edit_lag "nobody/nope" "coderabbitai")
	if [[ "$lag" == "30" ]]; then
		print_result "get_min_edit_lag returns global default for unknown repo" 0
	else
		print_result "get_min_edit_lag returns global default for unknown repo" 1 \
			"got '${lag}', expected '30'"
	fi
	return 0
}

# ---------- Unit tests: two-phase bot behaviour (GH#20550) ----------

test_two_phase_coderabbitai_aged_unedited_not_settled() {
	# Two-phase bot: coderabbitai, created 120s ago but never edited (edit_delta=0).
	# Age alone is NOT sufficient for coderabbitai — requires edit_delta > 0.
	local now created
	now=$(date +%s)
	created=$(TZ=UTC date -u -r "$((now - 120))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 120))" +"%Y-%m-%dT%H:%M:%SZ")
	if ! _comment_is_settled "$created" "$created" 30 "coderabbitai"; then
		print_result "two-phase: coderabbitai aged-unedited NOT settled (age alone insufficient)" 0
	else
		print_result "two-phase: coderabbitai aged-unedited NOT settled (age alone insufficient)" 1 \
			"created=${created} — expected NOT settled; age-derived branch must be skipped for coderabbitai"
	fi
	return 0
}

test_two_phase_coderabbitai_any_edit_settled() {
	# Two-phase bot: coderabbitai, created 10s ago, edited 5s ago (edit_delta=5).
	# Any positive edit_delta is authoritative for coderabbitai — settled immediately.
	# Under non-two-phase OR semantics this same input is NOT settled
	# (edit_delta=5 < min_lag=30 AND age=10 < min_lag=30).
	local now created updated
	now=$(date +%s)
	created=$(TZ=UTC date -u -r "$((now - 10))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 10))" +"%Y-%m-%dT%H:%M:%SZ")
	updated=$(TZ=UTC date -u -r "$((now - 5))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 5))" +"%Y-%m-%dT%H:%M:%SZ")
	if _comment_is_settled "$created" "$updated" 30 "coderabbitai"; then
		print_result "two-phase: coderabbitai edit_delta=5 settled (any positive edit is authoritative)" 0
	else
		print_result "two-phase: coderabbitai edit_delta=5 settled (any positive edit is authoritative)" 1 \
			"created=${created} updated=${updated} — expected settled; edit_delta > 0 should pass for coderabbitai"
	fi
	return 0
}

test_two_phase_unknown_bot_or_semantics_preserved() {
	# Non-two-phase bot with unknown login: aged 120s, never edited.
	# Should settle by age — OR semantics preserved for non-two-phase bots.
	local now created
	now=$(date +%s)
	created=$(TZ=UTC date -u -r "$((now - 120))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 120))" +"%Y-%m-%dT%H:%M:%SZ")
	if _comment_is_settled "$created" "$created" 30 "gemini-code-assist"; then
		print_result "non-two-phase: unknown bot aged-unedited settled by age (OR semantics preserved)" 0
	else
		print_result "non-two-phase: unknown bot aged-unedited settled by age (OR semantics preserved)" 1 \
			"created=${created} — expected settled by age for gemini-code-assist (non-two-phase)"
	fi
	return 0
}

test_two_phase_env_override_respected_non_two_phase() {
	# Non-two-phase bot: pass min_lag=60 (representing env override), age=40s.
	# age=40 < min_lag=60 and no edit → NOT settled.
	# Confirms env-derived min_lag is still applied for non-two-phase bots
	# (the two-phase path does not interfere with OR semantics + larger lag).
	local now created
	now=$(date +%s)
	created=$(TZ=UTC date -u -r "$((now - 40))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
		date -u -d "@$((now - 40))" +"%Y-%m-%dT%H:%M:%SZ")
	if ! _comment_is_settled "$created" "$created" 60 "gemini-code-assist"; then
		print_result "non-two-phase: env min_lag=60, age=40 → NOT settled (override respected)" 0
	else
		print_result "non-two-phase: env min_lag=60, age=40 → NOT settled (override respected)" 1 \
			"created=${created} — expected NOT settled; age=40 is below min_lag=60"
	fi
	return 0
}

# ---------- Unit/integration tests: opt-in strict completion (GH#23066) ----------

install_strict_completion_gh_stub() {
	local scenario="$1"
	local gh_stub="${TEST_ROOT}/bin/gh"

	cat >"$gh_stub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

scenario="${REVIEW_BOT_GATE_GH_SCENARIO:?}"

if [[ "${1:-}" == "pr" && "${2:-}" == "view" ]]; then
	printf '%s\n' 'abc123def456'
	exit 0
fi

if [[ "${1:-}" != "api" ]]; then
	exit 2
fi

endpoint="${2:-}"
case "$endpoint" in
	repos/testorg/strictrepo/pulls/123/reviews)
		;;
	repos/testorg/strictrepo/issues/123/comments)
		printf '%s\t%s\t%s\n' \
			'2026-05-07T00:00:00Z' \
			'2026-05-07T00:00:05Z' \
			'UmV2aWV3IGNvbXBsZXRlZC4gTG9va3MgZ29vZC4='
		;;
	repos/testorg/strictrepo/pulls/123/comments)
		;;
	repos/testorg/strictrepo/commits/abc123def456/status?per_page=100)
		if [[ "$scenario" == "strict-success" ]]; then
			printf '%s\n' 'CodeRabbit'
		fi
		;;
	repos/testorg/strictrepo/commits/abc123def456/check-runs?per_page=100)
		;;
	*)
		;;
esac
EOF
	chmod +x "$gh_stub"
	export REVIEW_BOT_GATE_GH_SCENARIO="$scenario"
	return 0
}

test_get_completion_behavior_strict_repo() {
	local behavior
	behavior=$(_get_completion_behavior 'testorg/strictrepo' 'coderabbitai')
	if [[ "$behavior" == "strict" ]]; then
		print_result "completion_behavior resolves strict repo preference" 0
	else
		print_result "completion_behavior resolves strict repo preference" 1 \
			"got '${behavior}', expected 'strict'"
	fi
	return 0
}

test_get_completion_behavior_defaults_fast() {
	local behavior
	behavior=$(_get_completion_behavior 'testorg/otherrepo' 'coderabbitai')
	if [[ "$behavior" == "fast" ]]; then
		print_result "completion_behavior defaults to fast throughput mode" 0
	else
		print_result "completion_behavior defaults to fast throughput mode" 1 \
			"got '${behavior}', expected 'fast'"
	fi
	return 0
}

test_strict_coderabbit_pending_status_blocks_edited_comment() {
	install_strict_completion_gh_stub "strict-pending"

	if ! bot_has_real_review 123 'testorg/strictrepo' 'coderabbitai' 2>/dev/null; then
		print_result "strict CodeRabbit edited comment waits for SUCCESS status" 0
	else
		print_result "strict CodeRabbit edited comment waits for SUCCESS status" 1 \
			"expected strict mode to reject edited-comment evidence without CodeRabbit SUCCESS status"
	fi
	return 0
}

test_strict_coderabbit_success_status_passes_edited_comment() {
	install_strict_completion_gh_stub "strict-success"

	if bot_has_real_review 123 'testorg/strictrepo' 'coderabbitai' 2>/dev/null; then
		print_result "strict CodeRabbit accepts edited comment with SUCCESS status" 0
	else
		print_result "strict CodeRabbit accepts edited comment with SUCCESS status" 1 \
			"expected strict mode to accept CodeRabbit SUCCESS status evidence"
	fi
	return 0
}

test_any_bot_success_status_reuses_provided_contexts() {
	TEST_STATUS_FETCHES=0
	_get_success_status_contexts() {
		TEST_STATUS_FETCHES=$((TEST_STATUS_FETCHES + 1))
		return 1
	}

	local contexts
	contexts=$'abc123def456\nCodeRabbit'
	if any_bot_has_success_status 123 'testorg/strictrepo' "$contexts" 2>/dev/null && \
		[[ "$TEST_STATUS_FETCHES" -eq 0 ]]; then
		print_result "status fallback reuses provided success contexts" 0
	else
		print_result "status fallback reuses provided success contexts" 1 \
			"fetches=${TEST_STATUS_FETCHES}"
	fi
	return 0
}

test_any_bot_success_status_reuses_prepared_contexts() {
	local prepare_calls=0
	_prepare_success_status_contexts() {
		prepare_calls=$((prepare_calls + 1))
		return 1
	}

	local contexts
	contexts=$'abc123def456\ncoderabbit'
	if any_bot_has_success_status 123 'testorg/strictrepo' "$contexts" true 2>/dev/null && \
		[[ "$prepare_calls" -eq 0 ]]; then
		print_result "status fallback reuses prepared success contexts" 0
	else
		print_result "status fallback reuses prepared success contexts" 1 \
			"prepare_calls=${prepare_calls}"
	fi
	return 0
}

# ---------- Integration tests: reviews endpoint submitted_at-only TSV (GH#26473) ----------

install_submitted_at_only_review_gh_stub() {
	local gh_stub="${TEST_ROOT}/bin/gh"

	cat >"$gh_stub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

if [[ "${1:-}" != "api" ]]; then
	exit 2
fi

endpoint="${2:-}"
jq_filter=""
shift 2
while [[ "$#" -gt 0 ]]; do
	case "${1:-}" in
		--jq)
			jq_filter="${2:-}"
			shift 2
			;;
		*)
			shift
			;;
	esac
done

case "$endpoint" in
	repos/testorg/otherrepo/pulls/123/reviews)
		jq -r "$jq_filter" <<'JSON'
[
  {
    "user": {"login": "reviewbot"},
    "submitted_at": "2020-01-01T00:00:00Z",
    "body": "## Review\n\nLooks good. This is a real review body."
  }
]
JSON
		;;
	repos/testorg/otherrepo/issues/123/comments|repos/testorg/otherrepo/pulls/123/comments)
		jq -r "$jq_filter" <<'JSON'
[]
JSON
		;;
	*)
		jq -r "$jq_filter" <<'JSON'
[]
JSON
		;;
esac
EOF
	chmod +x "$gh_stub"
	return 0
}

test_bot_has_real_review_accepts_submitted_at_only_review() {
	install_submitted_at_only_review_gh_stub

	if bot_has_real_review 123 'testorg/otherrepo' 'reviewbot' 2>/dev/null; then
		print_result "bot_has_real_review accepts reviews endpoint submitted_at-only record" 0
	else
		print_result "bot_has_real_review accepts reviews endpoint submitted_at-only record" 1 \
			"expected submitted_at fallback to avoid leading TSV tab field shift"
	fi
	return 0
}

test_classify_bot_state_accepts_submitted_at_only_review() {
	install_submitted_at_only_review_gh_stub

	local state
	state=$(_classify_bot_state 123 'testorg/otherrepo' 'reviewbot')
	if [[ "$state" == "real-review" ]]; then
		print_result "_classify_bot_state accepts reviews endpoint submitted_at-only record" 0
	else
		print_result "_classify_bot_state accepts reviews endpoint submitted_at-only record" 1 \
			"state=${state}; expected real-review"
	fi
	return 0
}

# ---------- Unit tests: notice category classification (GH#22855) ----------

install_notice_category_gh_stub() {
	local scenario="$1"
	local gh_stub="${TEST_ROOT}/bin/gh"

	cat >"$gh_stub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

count_file="${REVIEW_BOT_GATE_GH_COUNT_FILE:?}"
scenario="${REVIEW_BOT_GATE_GH_SCENARIO:?}"
count="0"
if [[ -f "$count_file" ]]; then
	IFS= read -r count <"$count_file" || count="0"
fi
count=$((count + 1))
printf '%s\n' "$count" >"$count_file"

if [[ "${1:-}" != "api" ]]; then
	exit 2
fi

endpoint="${2:-}"
case "$scenario:$endpoint" in
	precedence:repos/testorg/otherrepo/pulls/123/reviews)
		printf '%s\n' 'UmV2aWV3IHNraXBwZWQgZHVlIHRvIHJhdGUgbGltaXQgZXhjZWVkZWQ='
		;;
	precedence:repos/testorg/otherrepo/issues/123/comments)
		;;
	precedence:repos/testorg/otherrepo/pulls/123/comments)
		printf '%s\n' 'UmV2aWV3IGZhaWxlZCDigJQgUHVsbCByZXF1ZXN0IHdhcyBjbG9zZWQgb3IgbWVyZ2VkIGR1cmluZyByZXZpZXcu'
		;;
	none:*)
		;;
	failure:*)
		exit 42
		;;
	*)
		;;
esac
EOF
	chmod +x "$gh_stub"
	printf '0\n' >"${TEST_ROOT}/gh-count"
	export REVIEW_BOT_GATE_GH_COUNT_FILE="${TEST_ROOT}/gh-count"
	export REVIEW_BOT_GATE_GH_SCENARIO="$scenario"
	return 0
}

read_notice_category_gh_count() {
	local count="0"
	if [[ -f "${TEST_ROOT}/gh-count" ]]; then
		IFS= read -r count <"${TEST_ROOT}/gh-count" || count="0"
	fi
	printf '%s\n' "$count"
	return 0
}

test_notice_category_single_pass_prefers_non_rate_limit() {
	install_notice_category_gh_stub "precedence"

	local output status calls
	if output=$(bot_get_notice_category 123 'testorg/otherrepo' 'coderabbitai'); then
		status=0
	else
		status=$?
	fi
	calls=$(read_notice_category_gh_count)

	if [[ "$status" -eq 0 && "$output" == "non-rate-limit" && "$calls" == "3" ]]; then
		print_result "notice category uses one pass and prefers non-rate-limit" 0
	else
		print_result "notice category uses one pass and prefers non-rate-limit" 1 \
			"status=${status} output=${output} calls=${calls}"
	fi
	return 0
}

test_notice_category_none_is_successful_default() {
	install_notice_category_gh_stub "none"

	local output status calls
	if output=$(bot_get_notice_category 123 'testorg/otherrepo' 'coderabbitai'); then
		status=0
	else
		status=$?
	fi
	calls=$(read_notice_category_gh_count)

	if [[ "$status" -eq 0 && "$output" == "none" && "$calls" == "3" ]]; then
		print_result "notice category returns successful none default" 0
	else
		print_result "notice category returns successful none default" 1 \
			"status=${status} output=${output} calls=${calls}"
	fi
	return 0
}

test_notice_category_propagates_api_failure() {
	install_notice_category_gh_stub "failure"

	local output status
	if output=$(bot_get_notice_category 123 'testorg/otherrepo' 'coderabbitai'); then
		status=0
	else
		status=$?
	fi

	if [[ "$status" -eq 42 && -z "$output" ]]; then
		print_result "notice category propagates API failure" 0
	else
		print_result "notice category propagates API failure" 1 \
			"status=${status} output=${output}"
	fi
	return 0
}

# ---------- Integration tests: do_check decision buckets (GH#22802) ----------

test_do_check_passes_true_rate_limit_only() {
	check_for_skip_label() { return 1; }
	get_all_bot_commenters() { printf '%s\n' 'coderabbitai'; return 0; }
	_get_success_status_contexts() { return 1; }
	bot_has_real_review() { return 1; }
	bot_get_notice_category() { echo "rate-limit"; return 0; }
	any_bot_has_success_status() { return 1; }

	local output status
	if output=$(do_check 123 'testorg/otherrepo' 2>/dev/null); then
		status=0
	else
		status=$?
	fi

	if [[ "$status" -eq 0 && "$output" == "PASS_RATE_LIMITED" ]]; then
		print_result "do_check passes true rate-limit notices by default" 0
	else
		print_result "do_check passes true rate-limit notices by default" 1 \
			"status=${status} output=${output}"
	fi
	return 0
}

test_do_check_blocks_non_rate_limit_non_review_states() {
	check_for_skip_label() { return 1; }
	get_all_bot_commenters() { printf '%s\n' 'coderabbitai'; return 0; }
	_get_success_status_contexts() { return 1; }
	bot_has_real_review() { return 1; }
	bot_get_notice_category() { echo "non-rate-limit"; return 0; }
	any_bot_has_success_status() { return 1; }

	local output status
	if output=$(do_check 123 'testorg/otherrepo' 2>/dev/null); then
		status=0
	else
		status=$?
	fi

	if [[ "$status" -eq 1 && "$output" == "WAITING" ]]; then
		print_result "do_check blocks non-rate-limit non-review states" 0
	else
		print_result "do_check blocks non-rate-limit non-review states" 1 \
			"status=${status} output=${output}"
	fi
	return 0
}

test_do_check_accepts_non_review_with_success_status() {
	check_for_skip_label() { return 1; }
	get_all_bot_commenters() { printf '%s\n' 'coderabbitai'; return 0; }
	_get_success_status_contexts() { printf '%s\n' 'abc123def456' 'CodeRabbit'; return 0; }
	bot_has_real_review() { return 1; }
	bot_get_notice_category() { echo "non-rate-limit"; return 0; }
	any_bot_has_success_status() { return 0; }

	local output status
	if output=$(do_check 123 'testorg/otherrepo' 2>/dev/null); then
		status=0
	else
		status=$?
	fi

	if [[ "$status" -eq 0 && "$output" == "PASS" ]]; then
		print_result "do_check accepts non-review state with success status" 0
	else
		print_result "do_check accepts non-review state with success status" 1 \
			"status=${status} output=${output}"
	fi
	return 0
}

test_do_check_fetches_success_status_contexts_once() {
	check_for_skip_label() { return 1; }
	get_all_bot_commenters() { printf '%s\n' 'coderabbitai gemini-code-assist'; return 0; }
	bot_has_real_review() { return 1; }
	bot_get_notice_category() { echo "non-rate-limit"; return 0; }
	_get_success_status_contexts() {
		local pr_number="$1"
		local repo="$2"
		local count="0"
		if [[ -f "${TEST_ROOT}/status-fetch-count" ]]; then
			IFS= read -r count <"${TEST_ROOT}/status-fetch-count" || count="0"
		fi
		count=$((count + 1))
		printf '%s\n' "$count" >"${TEST_ROOT}/status-fetch-count"
		printf '%s\n%s\n' "abc123def456" "CodeRabbit"
		return 0
	}

	printf '0\n' >"${TEST_ROOT}/status-fetch-count"
	local output status calls
	if output=$(do_check 123 'testorg/otherrepo' 2>/dev/null); then
		status=0
	else
		status=$?
	fi
	IFS= read -r calls <"${TEST_ROOT}/status-fetch-count" || calls="0"

	if [[ "$status" -eq 0 && "$output" == "PASS" && "$calls" == "1" ]]; then
		print_result "do_check fetches success status contexts once" 0
	else
		print_result "do_check fetches success status contexts once" 1 \
			"status=${status} output=${output} calls=${calls}"
	fi
	return 0
}

# ---------- Run ----------

main() {
	setup_test_env
	trap cleanup_test_env EXIT

	# Source the helper (with main() invocation stripped) so its functions
	# are callable in this shell.
	load_helper_functions

	echo "=== Pattern matching ==="
	test_is_non_review_comment_matches_rate_limit
	test_is_non_review_comment_matches_review_failed
	test_is_non_review_comment_matches_review_skipped
	test_is_non_review_comment_matches_closed_during_review
	test_is_non_review_comment_rejects_real_review
	test_is_rate_limit_only_matches_rate_limit
	test_is_rate_limit_only_rejects_review_failed
	test_is_rate_limit_only_rejects_review_skipped
	test_is_rate_limit_only_rejects_real_review

	echo ""
	echo "=== Settled check ==="
	test_settled_recent_unedited_placeholder_rejected
	test_settled_old_unedited_accepted
	test_settled_recent_edited_accepted
	test_settled_edit_delta_over_min_lag_accepted
	test_settled_missing_timestamps_conservative_pass
	test_settled_unparseable_timestamps_conservative_pass

	echo ""
	echo "=== Min-edit-lag resolver ==="
	test_get_min_edit_lag_per_tool
	test_get_min_edit_lag_per_repo
	test_get_min_edit_lag_global_default
	test_get_min_edit_lag_unknown_repo_default

	echo ""
	echo "=== Two-phase bot behaviour (GH#20550) ==="
	test_two_phase_coderabbitai_aged_unedited_not_settled
	test_two_phase_coderabbitai_any_edit_settled
	test_two_phase_unknown_bot_or_semantics_preserved
	test_two_phase_env_override_respected_non_two_phase

	echo ""
	echo "=== Opt-in strict completion (GH#23066) ==="
	test_get_completion_behavior_strict_repo
	test_get_completion_behavior_defaults_fast
	test_strict_coderabbit_pending_status_blocks_edited_comment
	test_strict_coderabbit_success_status_passes_edited_comment
	test_any_bot_success_status_reuses_provided_contexts
	test_any_bot_success_status_reuses_prepared_contexts

	echo ""
	echo "=== Reviews endpoint submitted_at fallback (GH#26473) ==="
	test_bot_has_real_review_accepts_submitted_at_only_review
	test_classify_bot_state_accepts_submitted_at_only_review

	echo ""
	echo "=== Notice category classification (GH#22855) ==="
	test_notice_category_single_pass_prefers_non_rate_limit
	test_notice_category_none_is_successful_default
	test_notice_category_propagates_api_failure

	echo ""
	echo "=== do_check decision buckets (GH#22802) ==="
	test_do_check_passes_true_rate_limit_only
	test_do_check_blocks_non_rate_limit_non_review_states
	test_do_check_accepts_non_review_with_success_status
	test_do_check_fetches_success_status_contexts_once

	echo ""
	echo "Tests run: ${TESTS_RUN}, failed: ${TESTS_FAILED}"
	if [[ "$TESTS_FAILED" -gt 0 ]]; then
		return 1
	fi
	return 0
}

main "$@"
