#!/usr/bin/env bash
# Feature 021 governance grep gates.
#
# Runs the four architectural + security invariants that MUST hold on every
# CI run for the F021 code paths. Any violation fails the build.
#
# Exit codes:
#   0 — all gates pass
#   1 — at least one gate failed
#
# Invocation:
#   bash bin/verify-f021-gates.sh
#
# Extend by adding new `run_gate` sections below.
set -eu

PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PLUGIN_ROOT"

FAIL=0

run_gate() {
	local name="$1"
	local desc="$2"
	local hits="$3"

	if [ -n "$hits" ]; then
		printf '\033[1;31m✗ %s\033[0m — %s\n' "$name" "$desc"
		printf '%s\n' "$hits"
		FAIL=$((FAIL + 1))
	else
		printf '\033[1;32m✓ %s\033[0m\n' "$name"
	fi
}

# ---------------------------------------------------------------------------
# T118b — Principle A1: no add_action/add_filter/register_activation_hook in constructors.
# All wiring MUST live in Main.php via the Loader.
# ---------------------------------------------------------------------------
GATE_A1_HITS="$(
	grep -rEn 'function __construct[^{]*\{[^}]*(add_action|add_filter|add_shortcode|register_activation_hook)' \
		--include='*.php' \
		includes/OAuth \
		includes/Connectors \
		includes/Database/OAuthClients \
		includes/Database/OAuthTokens \
		includes/Database/OAuthAuthCodes \
		admin/Partials/ServerTabs/AIConnectorsTab.php \
	2>/dev/null || true
)"
run_gate 'T118b Principle A1 — no hook wiring in constructors' \
	'Move to Main::define_public_hooks / define_admin_hooks via the Loader.' \
	"$GATE_A1_HITS"

# ---------------------------------------------------------------------------
# T118c — Repository ↔ Query ↔ $wpdb layering.
# Controllers (includes/OAuth/*Controller.php + TokenValidator + UserLifecycle
# + Cleanup + OAuthRouter + PKCE) MUST NOT touch $wpdb directly.
# ---------------------------------------------------------------------------
GATE_LAYERING_HITS="$(
	grep -rEn '(\$wpdb|wpdb\(\)|global[[:space:]]+\$wpdb)' \
		includes/OAuth/DiscoveryController.php \
		includes/OAuth/AuthorizationController.php \
		includes/OAuth/TokenController.php \
		includes/OAuth/ClientRegistrationController.php \
		includes/OAuth/TokenValidator.php \
		includes/OAuth/UserLifecycle.php \
		includes/OAuth/Cleanup.php \
		includes/OAuth/OAuthRouter.php \
		includes/OAuth/PKCE.php \
	2>/dev/null || true
)"
run_gate 'T118c Controller/Repository/$wpdb layering' \
	'Controllers MUST NOT touch $wpdb directly. Route through Repositories → Queries.' \
	"$GATE_LAYERING_HITS"

# ---------------------------------------------------------------------------
# T118d — Presentation (Partials) layer must not touch $wpdb or BerlinDB Query
# classes directly. View code MUST route through Repositories, same layering
# rule as Controllers (T118c). Added 2026-07-12 after architecture review found
# AIConnectorsTab.php:273-326 bypassing the Repository line — T118c scanned
# Controllers only and missed it. Scope: F021/F024 admin partials.
# ---------------------------------------------------------------------------
PARTIAL_TARGETS=(
	admin/Partials/ServerTabs/AIConnectorsTab.php
)
GATE_PARTIAL_HITS="$(
	{
		for f in "${PARTIAL_TARGETS[@]}"; do
			[ -f "$f" ] || continue
			# Direct $wpdb access from the view layer.
			grep -En '(\$wpdb|wpdb\(\)|global[[:space:]]+\$wpdb)' "$f" 2>/dev/null || true
			# Direct BerlinDB Query access (skipping Repository).
			grep -En '\\?AcrossAI_MCP_Manager\\Includes\\Database\\OAuth[A-Za-z]+\\Query::instance' "$f" 2>/dev/null || true
		done
	}
)"
run_gate 'T118d Partial/Repository/$wpdb layering' \
	'Partials MUST NOT touch $wpdb or OAuth*\Query::instance directly. Route through Repositories.' \
	"$GATE_PARTIAL_HITS"

# ---------------------------------------------------------------------------
# F090 — `is_enabled` has exactly ONE sanctioned WRITER.
# ServerEnablement::set() enforces the server type's requirement on off -> on.
# A guard applied at each call site is a convention, not a boundary: F090's
# call-site list was built by grep and the security review then found a path it
# had missed. This gate makes the boundary self-maintaining — a future fourth
# writer fails CI instead of relying on review memory.
#
# Scoped to `update_item` deliberately. `'is_enabled' => N` ALSO appears
# legitimately in (a) query filters (`query( array( 'is_enabled' => 1 ) )`, a
# READ) and (b) creation arrays (`add_item`, which seeds 0 per A21's
# disabled-by-default rule). Matching the bare key flags all of those and the
# gate becomes noise people learn to ignore.
# ---------------------------------------------------------------------------
GATE_ENABLED_WRITER_HITS="$(
	grep -rEn -A2 "update_item\(" \
		--include='*.php' \
		includes/ admin/ public/ \
	2>/dev/null \
		| grep -E "'is_enabled'[[:space:]]*=>" \
		| grep -v 'ServerEnablement.php' || true
)"
run_gate 'F090 single is_enabled writer' \
	'Route every enable/disable through ServerEnablement::set() — it enforces the server type requirement on off -> on, and a direct update_item() bypasses it.' \
	"$GATE_ENABLED_WRITER_HITS"

# ---------------------------------------------------------------------------
# F090 — plugin presence has exactly ONE resolver, and it matches by DIRECTORY.
#
# Two failures this catches, both found by the post-implementation architecture
# review after PHPCS, PHPStan, a security review AND an earlier architecture
# review had all passed:
#
#   1. `slug/slug.php` is NOT a rule WordPress enforces. Three plugins active on
#      the dev site break it — insert-headers-and-footers/ihaf.php (WPCode),
#      sfwd-lms/sfwd_lms.php, wp-mail-smtp/wp_mail_smtp.php. A type whose
#      `requires` named any of them resolved as permanently unavailable ON A SITE
#      WHERE THE PLUGIN WAS RUNNING.
#   2. "One resolver" was satisfied on paper — every caller went through
#      ServerTypes::is_available() — while a SECOND implementation of the same
#      question lived in the admin card with different (correct) semantics. They
#      agreed only because the sibling's filename happens to match its folder.
#
# QuickConnectController is allow-listed: its install flow holds a REAL resolved
# plugin file, which is what is_plugin_active() is actually for.
# ---------------------------------------------------------------------------
# Gate deliberately NOT written as "no is_plugin_active() outside one class".
# That was tried and flagged CORRECT code: AIConnectorsPromoTab and
# AbilitiesManagerPromoCard both resolve the REAL plugin file first and then ask
# whether it is active, which is exactly what the function is for. A gate whose
# allow-list must grow for every legitimate caller becomes noise people learn to
# ignore — the same trap the is_enabled gate above documents. The defect is the
# CONVENTION, so that is what is gated.
GATE_PLUGIN_FILE_CONVENTION_HITS="$(
	grep -rEn "\\\$[a-z_]+ \. '/' \. \\\$[a-z_]+ \. '\.php'" \
		--include='*.php' \
		includes/ admin/ public/ \
	2>/dev/null || true
)"
run_gate 'F090 no slug/slug.php assumption' \
	'A plugin main file is frequently NOT named after its folder (acf.php, wp-seo.php, ihaf.php). Resolve the real file by directory prefix over get_plugins()/active_plugins.' \
	"$GATE_PLUGIN_FILE_CONVENTION_HITS"

# ---------------------------------------------------------------------------
# T119 — FR-040 column-width invariants.
# hash columns MUST be char(64); PKCE challenge MUST be char(43);
# token_family_id MUST be char(36). No narrowing.
# ---------------------------------------------------------------------------
GATE_WIDTH_HITS="$(
	grep -rEn "'length'[[:space:]]*=>[[:space:]]*'(16|20|24|32|40|48)'" \
		includes/Database/OAuthClients/Schema.php \
		includes/Database/OAuthTokens/Schema.php \
		includes/Database/OAuthAuthCodes/Schema.php \
	2>/dev/null | grep -E "token_hash|code_hash|code_challenge|client_secret_hash|metadata_fingerprint|token_family_id" || true
)"
run_gate 'T119 FR-040 cryptographic column widths' \
	'char(64) for SHA-256 hashes, char(43) for PKCE, char(36) for UUIDv4. DO NOT NARROW.' \
	"$GATE_WIDTH_HITS"

# ---------------------------------------------------------------------------
# T120 — S3 no-raw-at-rest.
# Every path that generates a raw secret MUST go through SecretsVault. Flags
# direct unqualified `random_bytes(` or bareword `random_token(` (NOT
# SecretsVault::random_token() method calls, which ARE the vault boundary).
# Also excludes docblock comment lines.
# ---------------------------------------------------------------------------
GATE_RAW_HITS="$(
	{
		grep -rEn 'random_bytes[[:space:]]*\(' includes/OAuth 2>/dev/null \
			| grep -vE 'SecretsVault\.php|\* ' \
			|| true
		# Bareword `random_token(` NOT preceded by `SecretsVault::` (or `::`).
		grep -rEn '(^|[^:])random_token[[:space:]]*\(' includes/OAuth 2>/dev/null \
			| grep -vE 'SecretsVault\.php|\* ' \
			|| true
	}
)"
run_gate 'T120 S3 raw-secret generation outside SecretsVault' \
	'Only SecretsVault should call random_bytes/random_token. Any other hit needs review.' \
	"$GATE_RAW_HITS"

# ---------------------------------------------------------------------------
# F091-1 — the reconciler is ADD-ONLY.
#
# This is not a style rule. F091 relaxes D28's migration contract for column
# ADDITIONS, and the entire justification for that relaxation is that adding a
# column cannot lose data while dropping or narrowing one can. The moment
# SchemaReconciler learns to DROP or MODIFY, the relaxation is unsound and every
# Schema change needs its callback back.
#
# So the property is enforced here rather than trusted to review. Code, not
# comments, and not memory.
# ---------------------------------------------------------------------------
GATE_RECONCILER_ADDONLY_HITS="$(
	grep -nE '(DROP[[:space:]]+COLUMN|MODIFY[[:space:]]+COLUMN|CHANGE[[:space:]]+COLUMN|DROP[[:space:]]+TABLE|TRUNCATE|DELETE[[:space:]]+FROM)' \
		includes/Database/SchemaReconciler.php 2>/dev/null \
		| grep -vE '^\s*[0-9]+:\s*(\*|//)' \
		|| true
)"
run_gate 'F091-1 SchemaReconciler is add-only' \
	'SchemaReconciler must never emit destructive DDL — that is the whole safety argument for relaxing D28.' \
	"$GATE_RECONCILER_ADDONLY_HITS"

# ---------------------------------------------------------------------------
# F091-2 — destructive DDL lives only in versioned migration callbacks.
#
# A DROP or MODIFY outside a Table's $upgrades callback has no version gate, so
# it cannot be retried, ordered, or reasoned about — and on a stamped table it
# may never run at all. Tests are excluded: they synthesise drift on purpose.
# ---------------------------------------------------------------------------
GATE_DESTRUCTIVE_DDL_HITS="$(
	grep -rEn '(DROP[[:space:]]+COLUMN|MODIFY[[:space:]]+COLUMN|CHANGE[[:space:]]+COLUMN)' \
		--include='*.php' \
		includes/ admin/ public/ 2>/dev/null \
		| grep -vE 'includes/Database/[A-Za-z]+/Table\.php' \
		| grep -vE ':[[:space:]]*(\*|//)' \
		|| true
)"
run_gate 'F091-2 destructive DDL only in versioned callbacks' \
	'DROP/MODIFY COLUMN belongs in a Table $upgrades callback, where a version gates and retries it.' \
	"$GATE_DESTRUCTIVE_DDL_HITS"

# ---------------------------------------------------------------------------
# F091-3 — every $upgrades entry has a matching method.
#
# BerlinDB resolves callbacks by name at runtime. A typo'd or renamed method is
# silent: get_callable() returns nothing, upgrade_to() returns false, and the
# version simply never advances — the table sits drifted with no error anywhere.
# ---------------------------------------------------------------------------
GATE_UPGRADE_MAP_HITS="$(
	for table_file in includes/Database/*/Table.php; do
		[ -f "$table_file" ] || continue

		awk -v file="$table_file" "
			/protected \\\$upgrades/ { in_map = 1 }
			in_map && /=> *'/ {
				line = \$0
				sub(/.*=> *'/, \"\", line)
				sub(/'.*/, \"\", line)
				if (line != \"\") { print line }
			}
			in_map && /\\);/ { in_map = 0 }
		" "$table_file" | while read -r method; do
			[ -n "$method" ] || continue
			if ! grep -qE "function[[:space:]]+${method}[[:space:]]*\(" "$table_file"; then
				echo "${table_file}: \$upgrades names ${method}() but no such method exists"
			fi
		done
	done
)"
run_gate 'F091-3 $upgrades callbacks all exist' \
	'A named callback with no method silently no-ops: the version never advances and the table stays drifted.' \
	"$GATE_UPGRADE_MAP_HITS"

echo ''
if [ "$FAIL" -eq 0 ]; then
	printf '\033[1;32mAll F021 governance gates passed.\033[0m\n'
	exit 0
else
	printf '\033[1;31m%d gate(s) failed.\033[0m\n' "$FAIL"
	exit 1
fi
