<?php
/**
 * BaseCloud Boost — Fleet Ops REST API (BaseCloud CRM)
 *
 * Read + safe-verb REST surface (`bcboost/v1`) that lets the BaseCloud CRM
 * operate the client fleet centrally: health telemetry, cache purge, cache
 * preload, and a scheduled PSI run. Deliberately 1.3.7-scoped — NO settings
 * writes, NO file writes, NO secrets ever leave the site. Worst-case
 * compromise of a pairing key = cache purges (by design).
 *
 * Security model (every request, both modes — fail CLOSED on any doubt):
 * - Kill-switch: `fleet_ops_enabled` defaults to FALSE; disabled or keyless
 *   installs answer 403 to everything.
 * - Bearer mode: `X-BCBoost-Key` header, hash_equals() against the per-site
 *   256-bit pairing key (Crypto-encrypted at rest, shown once at pairing).
 * - Strict mode (`fleet_ops_strict`) additionally requires:
 *     X-BCBoost-Timestamp  unix seconds, ±300s window
 *     X-BCBoost-Nonce      16+ chars, replay-cached for 10 minutes
 *     X-BCBoost-Signature  HMAC-SHA256 hex over
 *                          "METHOD\n/route\ntimestamp\nnonce\nrawbody"
 *                          keyed by the same pairing key, where /route is the
 *                          full REST route incl. namespace (e.g. /bcboost/v1/purge).
 * - Rate limit: 30 requests / 5 minutes per site (429), counted BEFORE key
 *   comparison so brute force burns the budget too.
 * - Audit: ring buffer of the last 50 requests (time, route, result, ip) in a
 *   non-autoloaded option, plus WP_DEBUG_LOG lines. Never logs key material.
 * - Mutations are POST-only; every response carries no-store headers.
 *
 * @package BaseCloud\Boost\Core
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Core;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use BaseCloud\Boost\Admin\Admin_Controller;
use BaseCloud\Boost\Compatibility\Host_Detector;
use BaseCloud\Boost\Optimizer\Critical_CSS;

/**
 * Registers and serves the authenticated `bcboost/v1` fleet-operations API.
 */
class Fleet_Ops {

	/** REST namespace for all fleet endpoints. */
	private const REST_NAMESPACE = 'bcboost/v1';

	/** Audit ring buffer option (never autoloaded — admin/API reads only). */
	private const AUDIT_OPTION = 'BCBOOST_fleet_ops_audit';

	/** Audit ring buffer length. */
	private const AUDIT_MAX = 50;

	/** Rate-limit bucket transient. */
	private const RATE_TRANSIENT = 'BCBOOST_fleet_ops_rate';

	/** Max requests per rate window. */
	private const RATE_LIMIT = 30;

	/** Rate window in seconds. */
	private const RATE_WINDOW = 300;

	/** Strict mode: accepted clock skew in seconds (either direction). */
	private const TIMESTAMP_TOLERANCE = 300;

	/** Strict mode: nonce replay-cache lifetime in seconds. */
	private const NONCE_TTL = 600;

	/** Strict mode: minimum nonce length. */
	private const NONCE_MIN_LENGTH = 16;

	/** Per-URL purge request cap (keeps a single call bounded). */
	private const MAX_PURGE_URLS = 50;

	/**
	 * Register the REST routes. Registration is unconditional — the kill-switch
	 * and pairing key are enforced per-request in authenticate(), so a disabled
	 * install answers an honest 403 instead of a confusing 404.
	 *
	 * @return void
	 */
	public function boot(): void {
		add_action( 'rest_api_init', [ $this, 'register_routes' ] );
	}

	/**
	 * Route table. Every route authenticates through authenticate() — never a
	 * bare '__return_true'. Mutations are POST-only (Varnish/proxies never
	 * cache them; GETs stay side-effect-free).
	 *
	 * @return void
	 */
	public function register_routes(): void {
		register_rest_route( self::REST_NAMESPACE, '/health', [
			'methods'             => \WP_REST_Server::READABLE,
			'callback'            => [ $this, 'route_health' ],
			'permission_callback' => [ $this, 'authenticate' ],
		] );

		register_rest_route( self::REST_NAMESPACE, '/purge', [
			'methods'             => \WP_REST_Server::CREATABLE,
			'callback'            => [ $this, 'route_purge' ],
			'permission_callback' => [ $this, 'authenticate' ],
			'args'                => [
				'scope' => [
					'type'              => 'string',
					'default'           => 'all',
					'enum'              => [ 'all', 'urls' ],
					'sanitize_callback' => 'sanitize_key',
				],
				'urls'  => [
					'type'    => 'array',
					'default' => [],
					'items'   => [ 'type' => 'string' ],
				],
			],
		] );

		register_rest_route( self::REST_NAMESPACE, '/preload', [
			'methods'             => \WP_REST_Server::CREATABLE,
			'callback'            => [ $this, 'route_preload' ],
			'permission_callback' => [ $this, 'authenticate' ],
		] );

		register_rest_route( self::REST_NAMESPACE, '/psi', [
			'methods'             => \WP_REST_Server::CREATABLE,
			'callback'            => [ $this, 'route_psi' ],
			'permission_callback' => [ $this, 'authenticate' ],
		] );
	}

	// ─── Authentication (permission_callback for every route) ─────────────────

	/**
	 * Authenticate a fleet request. Order matters: kill-switch → key present →
	 * bearer compare → site rate limit (valid-key requests only — a keyless
	 * flood must never consume the legit CRM's budget; failures are damped
	 * per-IP instead) → strict-mode timestamp/nonce/signature. Any failure is
	 * audited and fails CLOSED; the pairing key is never echoed or logged.
	 *
	 * @param \WP_REST_Request $request Incoming request.
	 * @return true|\WP_Error
	 */
	public function authenticate( \WP_REST_Request $request ) {
		$route = (string) $request->get_route();

		try {
			if ( ! (bool) BCBOOST_get_option( 'fleet_ops_enabled', false ) ) {
				$this->audit( $route, 'denied_disabled' );
				return new \WP_Error(
					'bcboost_fleet_ops_disabled',
					__( 'Fleet Ops is disabled on this site. Enable it under BaseCloud Boost → Advanced → Fleet Ops.', 'basecloud-boost' ),
					[ 'status' => 403 ]
				);
			}

			// BCBOOST_get_option() transparently decrypts Crypto-stored secrets.
			$key = (string) BCBOOST_get_option( 'fleet_ops_key', '' );
			if ( '' === $key ) {
				$this->audit( $route, 'denied_no_key' );
				return new \WP_Error(
					'bcboost_fleet_ops_no_key',
					__( 'No Fleet Ops pairing key is set. Generate one under BaseCloud Boost → Advanced → Fleet Ops.', 'basecloud-boost' ),
					[ 'status' => 403 ]
				);
			}

			// Key compare BEFORE any shared throttle: the site budget below must be
			// consumable only by holders of the real key, or a keyless flood could
			// 429 the legitimate CRM (denial-of-fleet-ops). hash_equals on a fixed
			// 64-char string is cheap, and a 256-bit random key makes unthrottled
			// brute force a non-issue; failed attempts are damped per-IP instead.
			$provided = trim( (string) $request->get_header( 'x-bcboost-key' ) );
			if ( '' === $provided || ! hash_equals( $key, $provided ) ) {
				$flooding = $this->count_auth_failure();
				$this->audit( $route, $flooding ? 'denied_bad_key_flood' : 'denied_bad_key' );
				return new \WP_Error(
					'bcboost_fleet_ops_bad_key',
					__( 'Invalid Fleet Ops key.', 'basecloud-boost' ),
					[ 'status' => $flooding ? 429 : 403 ]
				);
			}

			// Site budget: counted for VALID-key requests only (see above).
			if ( $this->rate_limited() ) {
				$this->audit( $route, 'denied_rate_limited' );
				return new \WP_Error(
					'bcboost_fleet_ops_rate_limited',
					__( 'Too many Fleet Ops requests — limit is 30 per 5 minutes.', 'basecloud-boost' ),
					[ 'status' => 429 ]
				);
			}

			if ( (bool) BCBOOST_get_option( 'fleet_ops_strict', false ) ) {
				$strict = $this->verify_strict( $request, $key );
				if ( true !== $strict ) {
					$this->audit( $route, 'denied_strict' );
					return $strict;
				}
			}

			$this->audit( $route, 'ok' );
			return true;
		} catch ( \Throwable $e ) {
			// Fail CLOSED: an auth-path failure must never grant access.
			$this->log( 'Fleet Ops auth error: ' . $e->getMessage() );
			$this->audit( $route, 'denied_error' );
			return new \WP_Error(
				'bcboost_fleet_ops_auth_error',
				__( 'Fleet Ops authentication failed.', 'basecloud-boost' ),
				[ 'status' => 403 ]
			);
		}
	}

	/**
	 * Strict-mode verification: timestamp window, nonce replay cache, and an
	 * HMAC-SHA256 signature over "METHOD\n/route\ntimestamp\nnonce\nrawbody".
	 * The nonce is only committed to the replay cache AFTER the signature
	 * verifies, so unauthenticated garbage cannot fill the transient table.
	 *
	 * @param \WP_REST_Request $request Incoming request.
	 * @param string           $key     Decrypted pairing key.
	 * @return true|\WP_Error
	 */
	private function verify_strict( \WP_REST_Request $request, string $key ) {
		$deny = static function ( string $message ): \WP_Error {
			return new \WP_Error( 'bcboost_fleet_ops_strict', $message, [ 'status' => 403 ] );
		};

		$timestamp = trim( (string) $request->get_header( 'x-bcboost-timestamp' ) );
		if ( '' === $timestamp || ! ctype_digit( $timestamp ) ) {
			return $deny( __( 'Strict mode: X-BCBoost-Timestamp header (unix seconds) is required.', 'basecloud-boost' ) );
		}
		if ( abs( time() - (int) $timestamp ) > self::TIMESTAMP_TOLERANCE ) {
			return $deny( __( 'Strict mode: request timestamp is outside the ±300 second window.', 'basecloud-boost' ) );
		}

		$nonce = trim( (string) $request->get_header( 'x-bcboost-nonce' ) );
		if ( strlen( $nonce ) < self::NONCE_MIN_LENGTH ) {
			return $deny( __( 'Strict mode: X-BCBoost-Nonce header of at least 16 characters is required.', 'basecloud-boost' ) );
		}
		$nonce_key = 'BCBOOST_fo_nonce_' . md5( $nonce );
		if ( false !== get_transient( $nonce_key ) ) {
			return $deny( __( 'Strict mode: nonce already used (replay rejected).', 'basecloud-boost' ) );
		}

		$signature = strtolower( trim( (string) $request->get_header( 'x-bcboost-signature' ) ) );
		if ( '' === $signature ) {
			return $deny( __( 'Strict mode: X-BCBoost-Signature header is required.', 'basecloud-boost' ) );
		}

		$canonical = strtoupper( (string) $request->get_method() ) . "\n"
			. (string) $request->get_route() . "\n"
			. $timestamp . "\n"
			. $nonce . "\n"
			. (string) $request->get_body();

		$expected = hash_hmac( 'sha256', $canonical, $key );
		if ( ! hash_equals( $expected, $signature ) ) {
			return $deny( __( 'Strict mode: signature mismatch.', 'basecloud-boost' ) );
		}

		// Signature valid — burn the nonce for the replay window.
		set_transient( $nonce_key, 1, self::NONCE_TTL );

		return true;
	}

	/**
	 * Count a failed authentication attempt against a per-IP damping bucket.
	 * Purpose: keep audit/log spam bounded and answer sustained keyless floods
	 * with 429 — WITHOUT ever affecting valid-key traffic (the legit CRM never
	 * enters this bucket). REMOTE_ADDR may be a shared proxy IP on some hosts;
	 * that only lumps ATTACKER traffic together — valid keys bypass this path
	 * entirely, so the CRM cannot be locked out.
	 *
	 * @return bool True when this IP is over the failure threshold (flooding).
	 */
	private function count_auth_failure(): bool {
		$ip     = isset( $_SERVER['REMOTE_ADDR'] )
			? (string) preg_replace( '/[^0-9a-fA-F:.]/', '', (string) $_SERVER['REMOTE_ADDR'] )
			: 'unknown';
		$bucket = 'BCBOOST_fo_fail_' . md5( $ip );
		$count  = (int) get_transient( $bucket ) + 1;
		set_transient( $bucket, $count, self::RATE_WINDOW );
		return $count > self::RATE_LIMIT;
	}

	/**
	 * Sliding-bucket rate limiter: 30 requests per 5 minutes, one bucket per
	 * site. Counted for VALID-KEY requests only — failed auth is damped by
	 * count_auth_failure() instead, so unauthenticated traffic can never
	 * consume the legitimate CRM's budget.
	 *
	 * @return bool True when the current request exceeds the budget.
	 */
	private function rate_limited(): bool {
		$bucket = get_transient( self::RATE_TRANSIENT );
		$bucket = is_array( $bucket ) ? $bucket : [];

		$start = (int) ( $bucket['start'] ?? 0 );
		$age   = time() - $start;

		if ( $start <= 0 || $age >= self::RATE_WINDOW ) {
			set_transient( self::RATE_TRANSIENT, [ 'start' => time(), 'count' => 1 ], self::RATE_WINDOW );
			return false;
		}

		$count = (int) ( $bucket['count'] ?? 0 ) + 1;
		set_transient( self::RATE_TRANSIENT, [ 'start' => $start, 'count' => $count ], max( 1, self::RATE_WINDOW - $age ) );

		return $count > self::RATE_LIMIT;
	}

	// ─── Routes ───────────────────────────────────────────────────────────────

	/**
	 * GET /health — read-only telemetry snapshot for the CRM fleet watchdog.
	 * Every sub-probe is individually fenced so one failing collector degrades
	 * to a null field instead of failing the whole health check.
	 *
	 * @param \WP_REST_Request $request Request (unused; auth already ran).
	 * @return \WP_REST_Response
	 */
	public function route_health( \WP_REST_Request $request ): \WP_REST_Response {
		$cache_stats = null;
		try {
			$cache_stats = ( new Page_Cache() )->get_cache_stats();
		} catch ( \Throwable $e ) {
			$this->log( 'Fleet Ops health: cache stats failed: ' . $e->getMessage() );
		}

		$last_purge = 0;
		try {
			$last_purge = (int) ( ( new Metrics_Collector() )->get_all()['last_purge'] ?? 0 );
		} catch ( \Throwable $e ) {
			$this->log( 'Fleet Ops health: metrics read failed: ' . $e->getMessage() );
		}

		$host      = 'unknown';
		$host_fpc  = false;
		try {
			$detector = new Host_Detector();
			$host     = $detector->detect();
			$host_fpc = $detector->detect_has_page_cache();
		} catch ( \Throwable $e ) {
			$this->log( 'Fleet Ops health: host detection failed: ' . $e->getMessage() );
		}

		$page_cache_enabled = (bool) BCBOOST_get_option( 'cache_enabled', true )
			&& (bool) apply_filters( 'BCBOOST_page_cache_enabled', true );

		return $this->respond( [
			'plugin_version'     => BCBOOST_VERSION,
			'php_version'        => PHP_VERSION,
			'wp_version'         => (string) get_bloginfo( 'version' ),
			'site_url'           => home_url( '/' ),
			'capabilities'       => [ 'purge', 'preload', 'psi' ],
			'cache'              => [
				'stats'              => $cache_stats,
				'page_cache_enabled' => $page_cache_enabled,
			],
			'conflicting_cachers' => $this->conflicting_cachers(),
			'host'               => [
				'detected'            => $host,
				'host_has_page_cache' => $host_fpc,
			],
			'webp'               => $this->webp_probe(),
			'critical_css'       => $this->critical_css_summary(),
			'last_purge'         => $last_purge,
			'server_time'        => time(),
			'fleet_ops'          => [
				'key_set' => '' !== (string) BCBOOST_get_option( 'fleet_ops_key', '' ),
				'strict'  => (bool) BCBOOST_get_option( 'fleet_ops_strict', false ),
			],
		] );
	}

	/**
	 * POST /purge — {scope:'all'} purges everything through the standard
	 * purge chain (page cache + minify cache + CDN/Varnish/host fan-out);
	 * {scope:'urls', urls:[…]} purges up to 50 same-host URLs. Never touches
	 * settings or files beyond what a wp-admin "Clear Cache" does.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function route_purge( \WP_REST_Request $request ) {
		try {
			$scope = (string) $request->get_param( 'scope' );

			if ( 'urls' === $scope ) {
				$urls = $this->sanitize_purge_urls( (array) $request->get_param( 'urls' ) );
				if ( empty( $urls ) ) {
					return new \WP_Error(
						'bcboost_fleet_ops_bad_urls',
						__( 'scope "urls" requires a non-empty "urls" array of same-site URLs (max 50).', 'basecloud-boost' ),
						[ 'status' => 400 ]
					);
				}

				$purge = new Cache_Purge();
				foreach ( $urls as $url ) {
					$purge->purge_url( $url );
				}

				return $this->respond( [
					'scope'  => 'urls',
					'purged' => count( $urls ),
					'urls'   => $urls,
				] );
			}

			( new Metrics_Collector() )->set( 'last_purge', time() );
			$summary = ( new Cache_Purge() )->purge_all();

			return $this->respond( [
				'scope'   => 'all',
				'summary' => $summary,
			] );
		} catch ( \Throwable $e ) {
			return $this->route_error( 'purge', $e );
		}
	}

	/**
	 * POST /preload — (re)start the cache preloader and return a progress
	 * snapshot. start_preload() itself no-ops safely (with an honest status)
	 * on hosts where Boost's page cache is inactive.
	 *
	 * @param \WP_REST_Request $request Request (unused; auth already ran).
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function route_preload( \WP_REST_Request $request ) {
		try {
			$preloader = new Cache_Preloader();
			$preloader->start_preload();

			return $this->respond( [
				'progress' => $preloader->get_preload_progress(),
			] );
		} catch ( \Throwable $e ) {
			return $this->route_error( 'preload', $e );
		}
	}

	/**
	 * POST /psi — schedule a single-event cron (+30s) that runs the existing
	 * BCBOOST_lighthouse_daily handler (mobile + desktop PSI → configured
	 * Lighthouse webhook). NEVER synchronous: a PSI run takes 60s+, far past
	 * any sane HTTP timeout; results arrive via the webhook, not this response.
	 *
	 * @param \WP_REST_Request $request Request (unused; auth already ran).
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function route_psi( \WP_REST_Request $request ) {
		try {
			$webhook_on  = (bool) BCBOOST_get_option( 'lighthouse_webhook_enabled', false );
			$webhook_url = trim( (string) BCBOOST_get_option( 'lighthouse_webhook_url', '' ) );

			if ( ! $webhook_on || '' === $webhook_url ) {
				return $this->respond( [
					'accepted' => false,
					'message'  => __( 'Lighthouse webhook is not configured on this site — enable it under BaseCloud Boost → Lighthouse first.', 'basecloud-boost' ),
				], 409 );
			}

			// The recurring handler self-gates on lighthouse_webhook_enabled,
			// so reusing its hook keeps exactly one code path for PSI sends.
			wp_schedule_single_event( time() + 30, 'BCBOOST_lighthouse_daily' );

			return $this->respond( [
				'accepted' => true,
				'message'  => __( 'PSI run scheduled (~30s). Results are delivered to the configured Lighthouse webhook.', 'basecloud-boost' ),
			], 202 );
		} catch ( \Throwable $e ) {
			return $this->route_error( 'psi', $e );
		}
	}

	// ─── Health sub-probes ────────────────────────────────────────────────────

	/**
	 * Active caching plugins that conflict with Boost (the quatro/
	 * sportscommerce dark-site class), plus server-level LiteSpeed — LSCache
	 * can serve stale pages even with no LiteSpeed plugin installed.
	 *
	 * @return string[] Plugin slugs / marker strings.
	 */
	private function conflicting_cachers(): array {
		$conflicts = [];

		try {
			if ( ! function_exists( 'is_plugin_active' ) ) {
				require_once ABSPATH . 'wp-admin/includes/plugin.php';
			}
			foreach ( Admin_Controller::CONFLICTING_PLUGINS as $plugin_file ) {
				if ( is_plugin_active( $plugin_file ) ) {
					$conflicts[] = dirname( $plugin_file );
				}
			}
		} catch ( \Throwable $e ) {
			$this->log( 'Fleet Ops health: conflict detection failed: ' . $e->getMessage() );
		}

		$software = strtolower( (string) ( $_SERVER['SERVER_SOFTWARE'] ?? '' ) );
		if ( strpos( $software, 'litespeed' ) !== false ) {
			$conflicts[] = 'litespeed-server';
		}

		return array_values( array_unique( $conflicts ) );
	}

	/**
	 * Cheap WebP self-probe: is serve_webp on, and does at least one .webp
	 * file exist in uploads? Bounded directory walk (500 entries max), no
	 * loopback request — /health must stay light enough for an hourly fleet
	 * sweep across every site.
	 *
	 * @return array{serve_webp:bool, webp_files_present:bool}
	 */
	private function webp_probe(): array {
		$present = false;

		try {
			$uploads = wp_get_upload_dir();
			$basedir = (string) ( $uploads['basedir'] ?? '' );

			if ( '' !== $basedir && is_dir( $basedir ) ) {
				$iterator = new \RecursiveIteratorIterator(
					new \RecursiveDirectoryIterator( $basedir, \FilesystemIterator::SKIP_DOTS )
				);
				$checked = 0;
				foreach ( $iterator as $file ) {
					/** @var \SplFileInfo $file */
					if ( ++$checked > 500 ) {
						break;
					}
					if ( strtolower( $file->getExtension() ) === 'webp' ) {
						$present = true;
						break;
					}
				}
			}
		} catch ( \Throwable $e ) {
			$this->log( 'Fleet Ops health: WebP probe failed: ' . $e->getMessage() );
		}

		return [
			'serve_webp'         => (bool) BCBOOST_get_option( 'serve_webp', true ),
			'webp_files_present' => $present,
		];
	}

	/**
	 * Critical-CSS store coverage: which templates hold CSS, how many per-URL
	 * overrides exist, and whether the store is flagged stale.
	 *
	 * @return array{enabled:bool, templates_filled:string[], url_overrides:int, stale:?array}
	 */
	private function critical_css_summary(): array {
		$summary = [
			'enabled'          => (bool) BCBOOST_get_option( 'critical_css_enabled', false ),
			'templates_filled' => [],
			'url_overrides'    => 0,
			'stale'            => null,
		];

		try {
			$store = Critical_CSS::get_store();
			foreach ( (array) ( $store['templates'] ?? [] ) as $template => $entry ) {
				if ( is_array( $entry ) && ! empty( $entry['css'] ) ) {
					$summary['templates_filled'][] = (string) $template;
				}
			}
			$summary['url_overrides'] = count( (array) ( $store['urls'] ?? [] ) );
			$summary['stale']         = Critical_CSS::get_stale();
		} catch ( \Throwable $e ) {
			$this->log( 'Fleet Ops health: critical CSS summary failed: ' . $e->getMessage() );
		}

		return $summary;
	}

	// ─── Audit ────────────────────────────────────────────────────────────────

	/**
	 * Last audit entries, newest first (drives the Advanced-tab card).
	 *
	 * @param int $limit Max rows.
	 * @return array<int, array{time:int, route:string, result:string, ip:string}>
	 */
	public static function get_audit( int $limit = 5 ): array {
		$rows = (array) get_option( self::AUDIT_OPTION, [] );
		return array_slice( array_reverse( $rows ), 0, max( 1, $limit ) );
	}

	/**
	 * Append to the audit ring buffer (last 50, never autoloaded) and mirror a
	 * WP_DEBUG_LOG line. Never fatal, never logs key material.
	 *
	 * @param string $route  REST route.
	 * @param string $result Outcome slug (ok / denied_*).
	 * @return void
	 */
	private function audit( string $route, string $result ): void {
		try {
			$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( (string) $_SERVER['REMOTE_ADDR'] ) : '';

			$rows   = (array) get_option( self::AUDIT_OPTION, [] );
			$rows[] = [
				'time'   => time(),
				'route'  => $route,
				'result' => $result,
				'ip'     => $ip,
			];
			$rows = array_slice( $rows, -self::AUDIT_MAX );

			if ( false === get_option( self::AUDIT_OPTION, false ) ) {
				add_option( self::AUDIT_OPTION, $rows, '', 'no' ); // never autoload
			} else {
				update_option( self::AUDIT_OPTION, $rows, false );
			}

			$this->log( 'Fleet Ops ' . $route . ' → ' . $result . ( '' !== $ip ? ' (' . $ip . ')' : '' ) );
		} catch ( \Throwable $e ) {
			// Auditing must never block the request path.
		}
	}

	// ─── Helpers ──────────────────────────────────────────────────────────────

	/**
	 * Validate purge URLs: strings only, valid HTTP(S), same host as this
	 * site (a fleet key must not be able to point purges anywhere else),
	 * capped at 50, deduplicated.
	 *
	 * @param array $urls Raw urls parameter.
	 * @return string[] Clean same-host URLs.
	 */
	private function sanitize_purge_urls( array $urls ): array {
		$site_host = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );
		$clean     = [];

		foreach ( $urls as $url ) {
			if ( count( $clean ) >= self::MAX_PURGE_URLS ) {
				break;
			}
			if ( ! is_string( $url ) ) {
				continue;
			}
			$url = esc_url_raw( trim( $url ), [ 'http', 'https' ] );
			if ( '' === $url || ! wp_http_validate_url( $url ) ) {
				continue;
			}
			if ( strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ) !== $site_host ) {
				continue;
			}
			$clean[ $url ] = $url;
		}

		return array_values( $clean );
	}

	/**
	 * Build a response with no-store headers — fleet responses must never be
	 * cached by any intermediary (they carry live state and follow mutations).
	 *
	 * @param array $data   Payload.
	 * @param int   $status HTTP status.
	 * @return \WP_REST_Response
	 */
	private function respond( array $data, int $status = 200 ): \WP_REST_Response {
		$response = new \WP_REST_Response( $data, $status );
		$response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' );
		$response->header( 'X-Robots-Tag', 'noindex' );
		return $response;
	}

	/**
	 * Uniform route-failure handling: log the detail, return a generic 500 —
	 * internals never leak into the API response, and a fleet-endpoint error
	 * never touches the front end (REST runs its own request).
	 *
	 * @param string     $route Route slug for the log line.
	 * @param \Throwable $e     Failure.
	 * @return \WP_Error
	 */
	private function route_error( string $route, \Throwable $e ): \WP_Error {
		$this->log( 'Fleet Ops /' . $route . ' failed: ' . $e->getMessage() );
		return new \WP_Error(
			'bcboost_fleet_ops_error',
			__( 'The Fleet Ops operation failed on this site — check the site debug log.', 'basecloud-boost' ),
			[ 'status' => 500 ]
		);
	}

	/**
	 * Debug logging under WP_DEBUG_LOG only. Never logs secrets.
	 *
	 * @param string $message Log line.
	 * @return void
	 */
	private function log( string $message ): void {
		if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			error_log( '[BaseCloud Boost] ' . $message );
		}
	}
}
