<?php

namespace IPS\forumfortress\Api;

require_once __DIR__ . '/FfApiResilience.php';

use IPS\Application;
use IPS\Db;
use IPS\Http\Url;
use IPS\Log;
use IPS\Request;
use IPS\Settings;
use Throwable;
use function array_map;
use function array_unique;
use function bin2hex;
use function array_values;
use function explode;
use function gmdate;
use function in_array;
use function is_array;
use function is_int;
use function json_decode;
use function json_encode;
use function ksort;
use function max;
use function mb_strpos;
use function microtime;
use function min;
use function parse_url;
use function preg_match_all;
use function random_bytes;
use function round;
use function rtrim;
use function sha1;
use function strtolower;
use function strtoupper;
use function time;
use function trim;
use function usort;
use function mb_substr;
use function method_exists;
use function strcmp;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

class Client
{
	public const PLATFORM = 'invision';
	public const PLUGIN_VERSION = '1.2.2-beta15';
	/** Minimum seconds between full hourly sync runs (task + HTTP traffic share this gate). */
	protected const HOURLY_SYNC_MIN_INTERVAL = 540;
	protected const ENDPOINT_HEALTH_REFRESH_SECONDS = 43200;
	protected const ENDPOINT_HEALTH_DEGRADED_REFRESH_SECONDS = 600;
	protected const ENDPOINT_HEALTH_SLOW_TRIGGER_MS = 100;
	protected const ENDPOINT_HEALTH_RECOVERY_MS = 80;
	protected const ENDPOINT_REFRESH_REQUEST_MAX_DELAY_SECONDS = 300;
	protected const CONNECTION_TEST_TIMEOUT_SECONDS = 2;
	protected const PLAN_REFRESH_SECONDS = 86400;
	protected const MODERATION_SYNC_SECONDS = 600;

	protected static bool $moderationSyncInProgress = FALSE;

	protected static bool $lastCheckHadTimeout = FALSE;

	/** @var array<string, mixed>|null */
	protected static ?array $pendingRegisterChallenge = NULL;

	public const FF_TIMEOUT_TAG = 'FFTimeout';

	public static function isEnabled(): bool
	{
		return (bool) Settings::i()->ff_enabled;
	}

	public static function baseUrl(): string
	{
		return rtrim( trim( Settings::i()->ff_api_base_url ), '/' );
	}

	public static function controlBaseUrl(): string
	{
		$configured = static::normaliseBaseUrl( trim( (string) Settings::i()->ff_control_base_url ) );
		if ( $configured !== '' )
		{
			return $configured;
		}

		return static::deriveControlPlaneBaseFromManual( static::baseUrl() );
	}

	protected static function preferredBaseOverride(): string
	{
		return static::normaliseBaseUrl( trim( (string) ( Settings::i()->ff_preferred_endpoint ?? '' ) ) );
	}

	protected static function deriveControlPlaneBaseFromManual( string $manual ): string
	{
		$manual = static::normaliseBaseUrl( $manual );
		if ( $manual === '' )
		{
			return '';
		}
		$host = parse_url( $manual, PHP_URL_HOST );
		if ( !is_string( $host ) || $host === '' )
		{
			return '';
		}
		$host = strtolower( $host );
		if ( strpos( $host, 'api.' ) === 0 && strpos( $host, '.' ) !== FALSE )
		{
			return static::normaliseBaseUrl( 'https://control.' . substr( $host, 4 ) );
		}
		if ( strpos( $host, 'control.' ) === 0 )
		{
			return $manual;
		}

		return '';
	}

	protected static function hotFailoverApiBaseUrl(): string
	{
		return \FfApiResilience::hotFailoverApiBaseUrl( static::baseUrl(), static::controlBaseUrl() );
	}

	/** @return list<string> */
	protected static function edgeBasesFromState(): array
	{
		$state = static::loadEndpointState();
		$endpointList = is_array( $state['endpoints'] ?? NULL ) ? $state['endpoints'] : [];
		$edges = [];
		foreach ( $endpointList as $row )
		{
			$edges[] = (string) $row;
		}

		return $edges;
	}

	/** @return list<string> */
	protected static function bootstrapBasesOrdered(): array
	{
		return \FfApiResilience::bootstrapBasesOrdered(
			static::controlBaseUrl(),
			static::hotFailoverApiBaseUrl(),
			static::baseUrl(),
			static::edgeBasesFromState()
		);
	}

	/** @return list<string> */
	protected static function catalogFetchBases(): array
	{
		return \FfApiResilience::catalogFetchBases(
			static::controlBaseUrl(),
			static::hotFailoverApiBaseUrl(),
			static::edgeBasesFromState()
		);
	}

	/** @return list<string> */
	protected static function controlPlaneRequestBases(): array
	{
		return static::catalogFetchBases();
	}

	/**
	 * @param list<string> $endpoints
	 * @return list<string>
	 */
	protected static function normalisedEndpointList( array $endpoints ): array
	{
		$normalised = array_values( array_unique( array_map( static fn( $u ) => static::normaliseBaseUrl( (string) $u ), $endpoints ) ) );
		$normalised = array_values( array_filter( $normalised, static fn( $u ) => $u !== '' ) );
		sort( $normalised );

		return $normalised;
	}

	/**
	 * @param list<string> $previous
	 * @param list<string> $next
	 */
	protected static function endpointCatalogChanged( array $previous, array $next ): bool
	{
		return static::normalisedEndpointList( $previous ) !== static::normalisedEndpointList( $next );
	}

	/**
	 * @param array<string, mixed> $state
	 */
	protected static function invalidateEndpointHealthState( array &$state ): void
	{
		$state['last_health_at'] = 0;
		$state['health_day'] = '';
	}

	protected static function fetchNodeEndpointsCatalog( bool $force = FALSE ): bool
	{
		$state = static::loadEndpointState();
		$previousEndpoints = is_array( $state['endpoints'] ?? NULL ) ? $state['endpoints'] : [];
		$now = time();
		if ( !$force && !\FfApiResilience::isEndpointCatalogStale( $state ) )
		{
			return TRUE;
		}
		if ( !$force && \FfApiResilience::shouldBackoffEndpointCatalogRefresh( $state, $now ) )
		{
			return FALSE;
		}

		$urls = [];
		$endpointMeta = [];
		foreach ( static::catalogFetchBases() as $catalogBase )
		{
			$res = static::rawRequest( 'GET', $catalogBase, '/v1/node-endpoints', [], self::CONNECTION_TEST_TIMEOUT_SECONDS );
			if ( ( $res['status'] ?? 0 ) < 200 || ( $res['status'] ?? 0 ) >= 300 || !is_array( $res['data'] ?? NULL ) )
			{
				continue;
			}
			$endpoints = $res['data']['endpoints'] ?? NULL;
			if ( !is_array( $endpoints ) )
			{
				continue;
			}
			$state['control_check_fallback'] = !empty( $res['data']['control_check_fallback'] );
			$endpointMeta = [];
			foreach ( $endpoints as $row )
			{
				if ( !is_array( $row ) )
				{
					continue;
				}
				$url = static::normaliseBaseUrl( (string) ( $row['url'] ?? '' ) );
				if ( $url === '' )
				{
					continue;
				}
				$urls[ $url ] = TRUE;
				$endpointMeta[ $url ] = [
					'check_ready' => array_key_exists( 'check_ready', $row ) ? (bool) $row['check_ready'] : NULL,
					'status' => isset( $row['status'] ) ? (string) $row['status'] : '',
					'role' => isset( $row['role'] ) ? (string) $row['role'] : '',
					'traffic_tier' => array_key_exists( 'traffic_tier', $row )
						? \FfApiResilience::normaliseTrafficTier( $row['traffic_tier'] )
						: NULL,
				];
			}
			if ( $urls )
			{
				break;
			}
		}
		if ( !$urls )
		{
			\FfApiResilience::noteEndpointCatalogRefreshFailure( $state, $now );
			static::saveEndpointState( $state );

			return FALSE;
		}
		$newEndpoints = array_values( array_keys( $urls ) );
		if ( static::endpointCatalogChanged( $previousEndpoints, $newEndpoints ) )
		{
			static::invalidateEndpointHealthState( $state );
		}
		$state['catalog_fetched_at'] = $now;
		$state['endpoints'] = $newEndpoints;
		if ( $endpointMeta )
		{
			$state['endpoint_meta'] = $endpointMeta;
		}
		\FfApiResilience::noteEndpointCatalogRefreshSuccess( $state );
		static::saveEndpointState( $state );

		return TRUE;
	}

	public static function refreshEndpointCatalogIfStale(): void
	{
		if ( !static::isEnabled() || static::normaliseBaseUrl( static::baseUrl() ) === '' )
		{
			return;
		}

		try
		{
			static::fetchNodeEndpointsCatalog( FALSE );
		}
		catch ( Throwable $e )
		{
		}
	}

	protected static function maybeRefreshEndpointCatalogAfterCheckIn( string $requestPath ): void
	{
		if ( !\FfApiResilience::shouldRefreshEndpointCatalogOnCheckIn( $requestPath ) )
		{
			return;
		}

		static::refreshEndpointCatalogIfStale();
	}

	protected static function isCatalogBackupRole( ?string $role ): bool
	{
		$role = strtolower( trim( (string) $role ) );

		return in_array( $role, [ 'backup', 'control-fallback', 'control' ], TRUE );
	}

	protected static function isCatalogBackupEndpointUrl( string $baseUrl, ?string $role = NULL ): bool
	{
		if ( static::isCatalogBackupRole( $role ) )
		{
			return TRUE;
		}
		$control = static::normaliseBaseUrl( static::controlBaseUrl() );

		return $control !== '' && static::normaliseBaseUrl( $baseUrl ) === $control;
	}

	protected static function isSharedApiRoundRobinBase( string $baseUrl ): bool
	{
		$manual = static::normaliseBaseUrl( static::baseUrl() );
		$baseUrl = static::normaliseBaseUrl( $baseUrl );
		if ( $manual === '' || $baseUrl !== $manual )
		{
			return FALSE;
		}
		$host = parse_url( $manual, PHP_URL_HOST );

		return is_string( $host ) && strpos( strtolower( $host ), 'api.' ) === 0;
	}

	protected static function shouldProbeEndpointHealth( string $baseUrl, array $state ): bool
	{
		$baseUrl = static::normaliseBaseUrl( $baseUrl );
		if ( $baseUrl === '' )
		{
			return FALSE;
		}
		$endpointMeta = is_array( $state['endpoint_meta'] ?? NULL ) ? $state['endpoint_meta'] : [];
		$meta = isset( $endpointMeta[ $baseUrl ] ) && is_array( $endpointMeta[ $baseUrl ] ) ? $endpointMeta[ $baseUrl ] : [];
		$role = isset( $meta['role'] ) ? (string) $meta['role'] : NULL;
		if ( static::isCatalogBackupEndpointUrl( $baseUrl, $role ) )
		{
			return !static::edgesHealthyForCheckTraffic( $state );
		}
		if ( !static::isSharedApiRoundRobinBase( $baseUrl ) )
		{
			return TRUE;
		}

		return FALSE;
	}

	protected static function normaliseBaseUrl( string $value ): string
	{
		return rtrim( trim( $value ), '/' );
	}

	public static function forumDomain(): string
	{
		$base = Settings::i()->base_url;
		$host = parse_url( (string) $base, PHP_URL_HOST );

		if ( $host )
		{
			return \FfApiResilience::normaliseDomain( static::stripHostPort( (string) $host ) );
		}

		$fallback = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';

		return \FfApiResilience::normaliseDomain( static::stripHostPort( (string) $fallback ) );
	}

	protected static function bootstrapDomain(): string
	{
		$state = static::loadEndpointState();
		$canonical = trim( (string) ( $state['offline_canonical_domain'] ?? '' ) );

		return $canonical !== '' ? $canonical : static::forumDomain();
	}

	protected static function isOfflineApiKey(): bool
	{
		return \FfApiResilience::isOfflineBootstrapKey( trim( (string) Settings::i()->ff_api_key ), NULL );
	}

	/**
	 * Align domain with forum_domains / API validation (no :port on host; IPv6 bracket hosts supported).
	 */
	protected static function stripHostPort( string $host ): string
	{
		$host = trim( $host );
		if ( $host === '' || mb_strpos( $host, ':' ) === FALSE )
		{
			return $host;
		}
		if ( isset( $host[0] ) && $host[0] === '[' )
		{
			$end = mb_strpos( $host, ']:' );
			if ( $end !== FALSE )
			{
				return strtolower( substr( $host, 1, $end - 1 ) );
			}

			return strtolower( $host );
		}
		$parsed = parse_url( 'http://' . $host );
		if ( !empty( $parsed['host'] ) )
		{
			return strtolower( (string) $parsed['host'] );
		}

		return strtolower( $host );
	}

	public static function bootstrapIfNeeded(): ?array
	{
		if ( !static::isEnabled() or trim( Settings::i()->ff_api_key ) !== '' )
		{
			return NULL;
		}

		if ( !static::bootstrapBasesOrdered() )
		{
			return NULL;
		}

		$timeout = max( 1, (int) Settings::i()->ff_timeout );
		$payload = [
			'domain' => static::bootstrapDomain(),
			'platform' => static::PLATFORM,
			'platform_version' => Application::load( 'core' )->version,
			'plugin_version' => static::PLUGIN_VERSION,
			'api_key' => NULL,
		];

		$response = NULL;
		$usedBase = '';
		$nodeHeader = '';
		foreach ( static::bootstrapBasesOrdered() as $base )
		{
			$raw = static::rawRequest( 'POST', $base, '/v1/site/bootstrap', $payload, $timeout );
			$status = (int) ( $raw['status'] ?? 0 );
			$data = is_array( $raw['data'] ?? NULL ) ? $raw['data'] : NULL;
			if ( $status >= 200 && $status < 300 && is_array( $data ) && !empty( $data['api_key'] ) )
			{
				$response = $data;
				$usedBase = static::normaliseBaseUrl( (string) $base );
				$nodeHeader = trim( (string) ( $raw['node_header'] ?? '' ) );
				break;
			}
		}
		if ( !$response && static::fetchNodeEndpointsCatalog( TRUE ) )
		{
			foreach ( static::bootstrapBasesOrdered() as $base )
			{
				$raw = static::rawRequest( 'POST', $base, '/v1/site/bootstrap', $payload, $timeout );
				$status = (int) ( $raw['status'] ?? 0 );
				$data = is_array( $raw['data'] ?? NULL ) ? $raw['data'] : NULL;
				if ( $status >= 200 && $status < 300 && is_array( $data ) && !empty( $data['api_key'] ) )
				{
					$response = $data;
					$usedBase = static::normaliseBaseUrl( (string) $base );
					$nodeHeader = trim( (string) ( $raw['node_header'] ?? '' ) );
					break;
				}
			}
		}

		if ( $response )
		{
			static::persistIdentity( $response, $usedBase );
			$state = static::loadEndpointState();
			$state['last_responded'] = $usedBase;
			$state['last_responded_node'] = $nodeHeader;
			$state['last_response_at'] = time();
			static::saveEndpointState( $state );
		}

		return $response;
	}

	public static function ensureIdentity(): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		$key = trim( (string) Settings::i()->ff_api_key );
		$siteId = trim( (string) Settings::i()->ff_site_id );

		if ( $key === '' )
		{
			$boot = static::bootstrapIfNeeded();
			if ( $boot !== NULL && !empty( $boot['api_key'] ) )
			{
				static::refreshEndpointCatalogAndHealth( TRUE, self::CONNECTION_TEST_TIMEOUT_SECONDS );
			}
			return $boot;
		}

		if ( $siteId === '' )
		{
			return static::siteStatus( self::CONNECTION_TEST_TIMEOUT_SECONDS );
		}

		static::refreshEndpointCatalogAndHealth( FALSE, self::CONNECTION_TEST_TIMEOUT_SECONDS );

		return NULL;
	}

	public static function checkRegisterFromRequest(): ?array
	{
		static::bootstrapIfNeeded();

		$username = (string) Request::i()->username;
		$email = (string) Request::i()->email_address;
		$payload = [
			'ip' => Request::i()->ipAddress(),
			'username' => $username,
			'email' => $email,
			'email_domain' => static::emailDomain( $email ),
			'user_agent' => (string) ( Request::i()->userAgent() ?? '' ),
			'account_age_seconds' => 0,
			'post_count' => 0,
			'content' => NULL,
			'links' => [],
		];

		return static::checkRegisterPayload( $payload );
	}

	public static function checkRegisterPayload( array $payload ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		static::$lastCheckHadTimeout = FALSE;
		$prepared = static::withCheckRequestId( static::preparePayload( $payload ) );
		$timeout = max( 1, min( 2, (int) Settings::i()->ff_timeout ) );

		$response = static::request( 'POST', '/v1/check/register', $prepared, $timeout );
		if ( $response === NULL && static::$lastCheckHadTimeout )
		{
			static::queueTimeoutPending( 'register', $prepared, [
				'remote_content_type' => 'member',
				'username' => (string) ( $payload['username'] ?? '' ),
				'title' => 'Registration (FFTimeout)',
			] );
		}
		if ( is_array( $response ) )
		{
			static::maybeRefreshEndpointCatalogAfterCheckIn( '/v1/check/register' );
		}

		return $response;
	}

	public static function lastCheckHadTimeout(): bool
	{
		return static::$lastCheckHadTimeout;
	}

	/**
	 * @param array<string, mixed> $checkPayload
	 * @param array<string, mixed> $context
	 */
	public static function queueTimeoutPending( string $endpoint, array $checkPayload, array $context = [] ): void
	{
		$state = static::loadEndpointState();
		$pending = $state['ff_timeout_pending'] ?? [];
		if ( !is_array( $pending ) )
		{
			$pending = [];
		}
		$remoteType = (string) ( $context['remote_content_type'] ?? 'pending_check' );
		$remoteId = (string) ( $context['remote_content_id'] ?? '' );
		if ( $remoteId === '' )
		{
			$remoteId = substr( sha1( json_encode( [ $endpoint, $checkPayload, time() ] ) ), 0, 24 );
		}
		$pending[ $remoteType . ':' . $remoteId ] = [
			'remote_content_type' => $remoteType,
			'remote_content_id' => $remoteId,
			'endpoint' => $endpoint,
			'check_payload' => $checkPayload,
			'username' => (string) ( $context['username'] ?? $checkPayload['username'] ?? '' ),
			'title' => (string) ( $context['title'] ?? 'FFTimeout' ),
			'excerpt' => (string) ( $context['excerpt'] ?? '' ),
			'queued_at' => time(),
		];
		$state['ff_timeout_pending'] = $pending;
		static::saveEndpointState( $state );
	}

	/**
	 * @return list<array<string, mixed>>
	 */
	protected static function timeoutPendingSyncItems(): array
	{
		$state = static::loadEndpointState();
		$pending = $state['ff_timeout_pending'] ?? [];
		if ( !is_array( $pending ) )
		{
			return [];
		}
		$items = [];
		foreach ( $pending as $entry )
		{
			if ( !is_array( $entry ) )
			{
				continue;
			}
			$checkPayload = $entry['check_payload'] ?? [];
			if ( !is_array( $checkPayload ) )
			{
				continue;
			}
			$items[] = [
				'remote_content_type' => (string) ( $entry['remote_content_type'] ?? 'pending_check' ),
				'remote_content_id' => (string) ( $entry['remote_content_id'] ?? '' ),
				'title' => (string) ( $entry['title'] ?? 'FFTimeout' ),
				'excerpt' => (string) ( $entry['excerpt'] ?? '' ),
				'username' => (string) ( $entry['username'] ?? '' ),
				'available_actions' => [ 'approve', 'reject' ],
				'payload' => [
					'source' => 'ff_timeout',
					'forumfortress_challenge_tag' => static::FF_TIMEOUT_TAG,
					'endpoint' => (string) ( $entry['endpoint'] ?? 'register' ),
					'check_payload' => $checkPayload,
					'fortress_public_reason' => 'Forum Fortress check timed out; queued for automatic recovery on next sync (FFTimeout).',
				],
			];
		}
		return $items;
	}

	/**
	 * @param array<string, mixed> $response
	 */
	public static function setPendingRegisterChallenge( array $response ): void
	{
		static::$pendingRegisterChallenge = $response;
	}

	public static function applyPendingRegisterChallenge( \IPS\Member $member ): void
	{
		$response = static::$pendingRegisterChallenge;
		static::$pendingRegisterChallenge = NULL;
		if ( !is_array( $response ) || (string) ( $response['decision'] ?? '' ) !== 'challenge' || !$member->member_id )
		{
			return;
		}

		static::holdMemberForReview( $member, $response );
	}

	/**
	 * @param array<string, mixed> $response
	 */
	protected static function holdMemberForReview( \IPS\Member $member, array $response ): void
	{
		$decisionId = (int) ( $response['decision_id'] ?? 0 );
		$held = [
			'member' => TRUE,
			'name' => (string) $member->name,
			'title' => 'Registration (Forum Fortress)',
			'content' => '',
			'forum_fortress' => [
				'decision_id' => $decisionId > 0 ? $decisionId : NULL,
				'challenge_tag' => 'ForumFortress_Challenge',
			],
		];

		try
		{
			$member->restrict_post = TRUE;
			$member->save();

			$existing = iterator_to_array( Db::i()->select(
				'approval_id',
				'core_approval_queue',
				[
					'approval_content_class=? AND approval_content_id=?',
					'IPS\\Member',
					$member->member_id,
				],
				NULL,
				1
			) );
			if ( !empty( $existing ) )
			{
				return;
			}

			Db::i()->insert( 'core_approval_queue', [
				'approval_content_class' => 'IPS\\Member',
				'approval_content_id' => $member->member_id,
				'approval_held_data' => json_encode( $held, JSON_UNESCAPED_SLASHES ),
				'approval_held_reason' => 'Forum Fortress challenge — manual review required',
			] );
		}
		catch ( Throwable $e )
		{
			Log::log( 'Forum Fortress could not hold registration for review: ' . $e->getMessage(), 'forumfortress' );
		}
	}

	public static function reportRegister( \IPS\Member $member ): void
	{
		if ( !static::isEnabled() or !$member->member_id )
		{
			return;
		}

		static::bootstrapIfNeeded();

		$email = trim( (string) $member->email );
		$payload = [
			'forum_id' => NULL,
			'username' => (string) $member->name,
			'email_domain' => static::emailDomain( $email ),
			'ip' => $member->ip_address ?: Request::i()->ipAddress(),
			'links' => [],
			'payload' => [
				'source' => 'invision_registration',
				'member_id' => $member->member_id,
			],
		];

		static::request( 'POST', '/v1/report/register', static::preparePayload( $payload ) );
	}

	public static function hourlySync(): void
	{
		if ( !static::isEnabled() )
		{
			return;
		}

		$gateState = static::loadEndpointState();
		$lastHourly = (int) ( $gateState['hourly_sync_last_at'] ?? 0 );
		if ( $lastHourly > 0 && ( time() - $lastHourly ) < self::HOURLY_SYNC_MIN_INTERVAL )
		{
			return;
		}
		$gateState['hourly_sync_last_at'] = time();
		static::saveEndpointState( $gateState );

		try
		{
			static::bootstrapIfNeeded();
		}
		catch ( Throwable $e )
		{
		}

		try
		{
			static::refreshEndpointCatalogAndHealth();
		}
		catch ( Throwable $e )
		{
		}

		try
		{
			if ( static::shouldRunDailyTask( 'plugin_release_last_at' ) )
			{
				static::pluginRelease();
				static::markDailyTaskRun( 'plugin_release_last_at' );
			}
		}
		catch ( Throwable $e )
		{
		}

		try
		{
			static::sitePing( self::CONNECTION_TEST_TIMEOUT_SECONDS );
		}
		catch ( Throwable $e )
		{
		}

		try
		{
			static::refreshPlanCacheIfStale( TRUE );
		}
		catch ( Throwable $e )
		{
		}

		try
		{
			static::runModerationSyncCycle( TRUE );
		}
		catch ( Throwable $e )
		{
		}
	}

	public static function sitePing( ?int $timeoutOverride = null ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		$siteId = trim( Settings::i()->ff_site_id );
		$apiKey = trim( Settings::i()->ff_api_key );
		if ( $siteId === '' || $apiKey === '' )
		{
			return NULL;
		}

		$payload = static::request( 'POST', '/v1/site/ping', [
			'api_key' => $apiKey,
			'site_id' => $siteId,
			'domain' => static::forumDomain(),
			'platform' => static::PLATFORM,
			'platform_version' => Application::load( 'core' )->version,
			'plugin_version' => static::PLUGIN_VERSION,
		], $timeoutOverride ?? self::CONNECTION_TEST_TIMEOUT_SECONDS );
		if ( is_array( $payload ) )
		{
			$state = static::loadEndpointState();
			$state['last_site_ping_at'] = time();
			static::saveEndpointState( $state );
		}
		return $payload;
	}

	public static function checkContent( string $endpoint, array $payload ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		static::bootstrapIfNeeded();

		$prepared = static::withCheckRequestId( static::preparePayload( $payload ) );
		$response = static::request( 'POST', '/v1/check/' . $endpoint, $prepared );
		if ( $response === NULL && $endpoint === 'contact_page' )
		{
			$legacy = $prepared;
			$legacy['check_endpoint'] = 'contact_page';
			$response = static::request( 'POST', '/v1/check', $legacy );
		}
		return $response;
	}

	public static function health( ?int $timeoutOverride = null ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		return static::request( 'GET', '/health', [], $timeoutOverride ?? self::CONNECTION_TEST_TIMEOUT_SECONDS );
	}

	public static function capabilities( ?int $timeoutOverride = null ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		return static::requestFromControlPlane( 'GET', '/v1/capabilities', [], $timeoutOverride ?? self::CONNECTION_TEST_TIMEOUT_SECONDS );
	}

	public static function siteStatus( ?int $timeoutOverride = null ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		$key = trim( Settings::i()->ff_api_key );
		if ( $key === '' )
		{
			return NULL;
		}

		return static::request( 'GET', '/v1/site/status', [
			'api_key' => $key,
			'domain' => static::forumDomain(),
		], $timeoutOverride ?? self::CONNECTION_TEST_TIMEOUT_SECONDS );
	}

	public static function forumStats( ?int $timeoutOverride = null ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		$key = trim( Settings::i()->ff_api_key );
		if ( $key === '' )
		{
			return NULL;
		}

		return static::request( 'GET', '/v1/forum/stats', [
			'api_key' => $key,
			'domain' => static::forumDomain(),
		], $timeoutOverride ?? self::CONNECTION_TEST_TIMEOUT_SECONDS );
	}

	public static function pluginRelease( ?int $timeoutOverride = null ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		return static::requestFromControlPlane( 'GET', '/v1/plugin-release', [
			'platform' => static::PLATFORM,
			'current_version' => static::PLUGIN_VERSION,
		], $timeoutOverride ?? self::CONNECTION_TEST_TIMEOUT_SECONDS );
	}

	public static function registerSite( string $email ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		$siteId = trim( Settings::i()->ff_site_id );
		if ( $siteId === '' )
		{
			static::bootstrapIfNeeded();
			$siteId = trim( Settings::i()->ff_site_id );
		}

		if ( $siteId === '' || $email === '' )
		{
			return NULL;
		}

		$payload = [
			'domain' => static::forumDomain(),
			'email' => trim( $email ),
			'site_id' => $siteId,
		];
		$apiKey = trim( (string) Settings::i()->ff_api_key );
		if ( $apiKey !== '' )
		{
			$payload['api_key'] = $apiKey;
		}

		$response = static::request( 'POST', '/v1/site/register', $payload );
		if ( is_array( $response ) )
		{
			static::persistIdentity( $response );
		}
		return $response;
	}

	public static function activateAttackMode(): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		$siteId = trim( Settings::i()->ff_site_id );
		if ( $siteId === '' )
		{
			return NULL;
		}

		return static::request( 'POST', '/v1/site/attack-mode', [
			'site_id' => $siteId,
		] );
	}

	public static function deactivateAttackMode(): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		$siteId = trim( Settings::i()->ff_site_id );
		if ( $siteId === '' )
		{
			return NULL;
		}

		return static::request( 'POST', '/v1/site/attack-mode/end', [
			'site_id' => $siteId,
		] );
	}

	public static function portalLaunch( ?int $timeoutOverride = null ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}

		$siteId = trim( Settings::i()->ff_site_id );
		$apiKey = trim( Settings::i()->ff_api_key );
		if ( $siteId === '' || $apiKey === '' )
		{
			static::bootstrapIfNeeded();
			$siteId = trim( Settings::i()->ff_site_id );
			$apiKey = trim( Settings::i()->ff_api_key );
		}

		if ( $siteId === '' || $apiKey === '' )
		{
			return NULL;
		}

		return static::request( 'POST', '/v1/site/portal', [
			'api_key' => $apiKey,
			'site_id' => $siteId,
			'domain' => static::forumDomain(),
			'platform' => static::PLATFORM,
			'platform_version' => Application::load( 'core' )->version,
			'plugin_version' => static::PLUGIN_VERSION,
		], $timeoutOverride ?? self::CONNECTION_TEST_TIMEOUT_SECONDS );
	}

	public static function runModerationSyncCycle( bool $force = FALSE ): void
	{
		if ( !static::isEnabled() or static::$moderationSyncInProgress )
		{
			return;
		}

		$siteId = trim( Settings::i()->ff_site_id );
		$apiKey = trim( Settings::i()->ff_api_key );
		if ( $siteId === '' || $apiKey === '' )
		{
			return;
		}

		$state = static::loadEndpointState();
		$lastSyncAt = (int) ( $state['moderation_last_sync_at'] ?? 0 );
		$intervalSeconds = static::getModerationSyncIntervalSeconds( $state );
		if ( !$force && ( time() - $lastSyncAt ) < $intervalSeconds )
		{
			return;
		}

		$rows = iterator_to_array(
			Db::i()->select(
				'*',
				'core_approval_queue',
				NULL,
				'approval_id DESC',
				200
			)
		);

		$items = [];
		foreach ( $rows as $row )
		{
			$class = (string) ( $row['approval_content_class'] ?? '' );
			$type = static::mapApprovalClassToType( $class );
			if ( $type === NULL )
			{
				continue;
			}

			$heldData = [];
			$decoded = json_decode( (string) ( $row['approval_held_data'] ?? '' ), TRUE );
			if ( is_array( $decoded ) )
			{
				$heldData = $decoded;
			}

			$contentType = static::mapApprovalClassToType( $class );
			$contentId = (int) ( $row['approval_content_id'] ?? 0 );
			$contentUrl = static::buildApprovalContentUrl( $contentType, $contentId );
			$availableActions = [ 'approve', 'reject' ];
			if ( !empty( $heldData['member'] ) )
			{
				$availableActions[] = 'spam_clean';
			}

			$decisionId = NULL;
			$ffHeld = $heldData['forum_fortress'] ?? NULL;
			if ( is_array( $ffHeld ) && isset( $ffHeld['decision_id'] ) && is_numeric( $ffHeld['decision_id'] ) )
			{
				$candidate = (int) $ffHeld['decision_id'];
				if ( $candidate > 0 )
				{
					$decisionId = $candidate;
				}
			}

			$payload = [
				'approval_id' => (int) ( $row['approval_id'] ?? 0 ),
				'content_type' => $contentType,
			];
			if ( $decisionId !== NULL )
			{
				$payload['decision_id'] = $decisionId;
			}
			if ( is_array( $ffHeld ) && (string) ( $ffHeld['challenge_tag'] ?? '' ) === static::FF_TIMEOUT_TAG )
			{
				$payload['source'] = 'ff_timeout';
				$payload['forumfortress_challenge_tag'] = static::FF_TIMEOUT_TAG;
				if ( isset( $ffHeld['endpoint'] ) )
				{
					$payload['endpoint'] = (string) $ffHeld['endpoint'];
				}
				if ( isset( $ffHeld['check_payload'] ) && is_array( $ffHeld['check_payload'] ) )
				{
					$payload['check_payload'] = $ffHeld['check_payload'];
				}
				if ( isset( $ffHeld['public_reason'] ) )
				{
					$payload['fortress_public_reason'] = (string) $ffHeld['public_reason'];
				}
			}

			$items[] = [
				'remote_content_type' => $type,
				'remote_content_id' => (string) $contentId,
				'title' => (string) ( $heldData['title'] ?? '' ) ?: NULL,
				'reason' => (string) ( $row['approval_held_reason'] ?? '' ),
				'excerpt' => (string) ( $heldData['content'] ?? '' ),
				'username' => (string) ( $heldData['name'] ?? '' ),
				'content_hash' => sha1( (string) ( $heldData['content'] ?? '' ) ),
				'content_url' => $contentUrl,
				'available_actions' => $availableActions,
				'payload' => $payload,
			];
		}

		static::$moderationSyncInProgress = TRUE;
		try
		{
			$syncPayload = static::request( 'POST', '/v1/moderation-queue/sync', [
				'api_key' => $apiKey,
				'site_id' => $siteId,
				'domain' => static::forumDomain(),
				'platform' => static::PLATFORM,
				'platform_version' => Application::load( 'core' )->version,
				'plugin_version' => static::PLUGIN_VERSION,
				'block_reject_action' => static::getBlockRejectAction(),
				'items' => array_merge( $items, static::timeoutPendingSyncItems() ),
			] );

			if ( is_array( $syncPayload ) && !empty( $syncPayload['queue_notes'] ) && is_array( $syncPayload['queue_notes'] ) )
			{
				static::applyQueueNotes( $syncPayload['queue_notes'] );
			}

			$pendingRemaining = 0;
			for ( $pass = 0; $pass < 8; $pass++ )
			{
				$actionsPayload = static::request( 'POST', '/v1/moderation-actions/pull', [
					'api_key' => $apiKey,
					'site_id' => $siteId,
					'domain' => static::forumDomain(),
					'platform' => static::PLATFORM,
					'platform_version' => Application::load( 'core' )->version,
					'plugin_version' => static::PLUGIN_VERSION,
					'limit' => 25,
				] );
				$actions = is_array( $actionsPayload['actions'] ?? NULL ) ? $actionsPayload['actions'] : [];
				$pendingRemaining = (int) ( $actionsPayload['pending_actions'] ?? 0 );
				if ( !$actions )
				{
					break;
				}
				$results = static::executeModerationActions( $actions );
				static::request( 'POST', '/v1/moderation-actions/ack', [
					'api_key' => $apiKey,
					'site_id' => $siteId,
					'domain' => static::forumDomain(),
					'platform' => static::PLATFORM,
					'platform_version' => Application::load( 'core' )->version,
					'plugin_version' => static::PLUGIN_VERSION,
					'results' => $results,
				] );
			}
			if ( is_array( $syncPayload ) )
			{
				$pendingRemaining = max( $pendingRemaining, (int) ( $syncPayload['pending_actions'] ?? 0 ) );
			}
			$state = static::loadEndpointState();
			$state['moderation_pending_actions'] = max( 0, $pendingRemaining );
			$state['moderation_last_sync_at'] = time();
			static::saveEndpointState( $state );
		}
		catch ( Throwable $e )
		{
			Log::log( 'Forum Fortress moderation sync failed: ' . $e->getMessage(), 'forumfortress' );
		}
		finally
		{
			static::$moderationSyncInProgress = FALSE;
		}
	}

	/**
	 * @param list<array<string, mixed>> $notes
	 */
	protected static function applyQueueNotes( array $notes ): void
	{
		foreach ( $notes as $note )
		{
			if ( !is_array( $note ) )
			{
				continue;
			}
			$type = (string) ( $note['remote_content_type'] ?? '' );
			$contentId = (int) ( $note['remote_content_id'] ?? 0 );
			$reason = trim( (string) ( $note['fortress_public_reason'] ?? '' ) );
			if ( $type === '' || $contentId <= 0 || $reason === '' )
			{
				continue;
			}
			if ( mb_strlen( $reason ) > 2000 )
			{
				$reason = mb_substr( $reason, 0, 2000 );
			}
			$class = static::mapTypeToApprovalClass( $type );
			if ( $class === NULL )
			{
				continue;
			}
			try
			{
				$row = iterator_to_array( Db::i()->select( 'approval_held_data', 'core_approval_queue', [
					'approval_content_class=? AND approval_content_id=?',
					$class,
					$contentId,
				], NULL, 1 ) );
				$held = [];
				if ( isset( $row[0]['approval_held_data'] ) )
				{
					$d = json_decode( (string) $row[0]['approval_held_data'], TRUE );
					$held = is_array( $d ) ? $d : [];
				}
				if ( !isset( $held['forum_fortress'] ) || !is_array( $held['forum_fortress'] ) )
				{
					$held['forum_fortress'] = [];
				}
				$held['forum_fortress']['public_reason'] = $reason;
				Db::i()->update(
					'core_approval_queue',
					[
						'approval_held_data' => json_encode( $held, JSON_UNESCAPED_SLASHES ),
						'approval_held_reason' => $reason,
					],
					[
						'approval_content_class=? AND approval_content_id=?',
						$class,
						$contentId,
					]
				);
			}
			catch ( Throwable $e )
			{
			}
		}
	}

	/**
	 * @param list<array<string, mixed>> $actions
	 * @return list<array<string, mixed>>
	 */
	protected static function executeModerationActions( array $actions ): array
	{
		$results = [];
		foreach ( $actions as $action )
		{
			$actionId = (int) ( $action['id'] ?? 0 );
			$type = (string) ( $action['remote_content_type'] ?? '' );
			$contentId = (int) ( $action['remote_content_id'] ?? 0 );
			$requested = (string) ( $action['action'] ?? '' );
			if ( $actionId <= 0 || $contentId <= 0 )
			{
				$results[] = [ 'id' => $actionId, 'status' => 'failed', 'message' => 'Invalid moderation action payload' ];
				continue;
			}

			$class = static::mapTypeToApprovalClass( $type );
			if ( $class === NULL )
			{
				$results[] = [ 'id' => $actionId, 'status' => 'failed', 'message' => 'Unsupported moderation action payload' ];
				continue;
			}

			try
			{
				$content = $class::load( $contentId );
				static::applyModerationActionToContent( $content, $requested );
				$results[] = [ 'id' => $actionId, 'status' => 'applied', 'message' => 'Action applied' ];
			}
			catch ( \OutOfRangeException $e )
			{
				$results[] = [ 'id' => $actionId, 'status' => 'applied', 'message' => 'Queue item no longer pending' ];
			}
			catch ( Throwable $e )
			{
				$results[] = [ 'id' => $actionId, 'status' => 'failed', 'message' => $e->getMessage() ];
			}
		}

		return $results;
	}

	protected static function applyModerationActionToContent( object $content, string $requested ): void
	{
		if ( $requested === 'approve' )
		{
			if ( method_exists( $content, 'approve' ) )
			{
				$content->approve();
				return;
			}
			if ( method_exists( $content, 'unhide' ) )
			{
				$content->unhide();
				return;
			}
		}
		else if ( $requested === 'reject' || $requested === 'spam_clean' )
		{
			if ( $requested === 'spam_clean' && method_exists( $content, 'spam' ) )
			{
				$content->spam();
				return;
			}
			if ( method_exists( $content, 'delete' ) )
			{
				$content->delete();
				return;
			}
		}

		throw new \RuntimeException( 'Unsupported moderation action for content type' );
	}

	protected static function mapTypeToApprovalClass( string $type ): ?string
	{
		if ( $type === 'thread' )
		{
			return 'IPS\\forums\\Topic';
		}
		if ( $type === 'post' )
		{
			return 'IPS\\forums\\Topic\\Post';
		}
		if ( $type === 'member' )
		{
			return 'IPS\\Member';
		}

		return NULL;
	}

	protected static function buildApprovalContentUrl( string $type, int $contentId ): ?string
	{
		$class = static::mapTypeToApprovalClass( $type );
		if ( $class === NULL || $contentId <= 0 )
		{
			return NULL;
		}
		try
		{
			$content = $class::load( $contentId );
			if ( method_exists( $content, 'url' ) )
			{
				return (string) $content->url();
			}
		}
		catch ( Throwable $e )
		{
		}

		return NULL;
	}

	protected static function withCheckRequestId( array $payload ): array
	{
		if ( !isset( $payload['check_request_id'] ) || trim( (string) $payload['check_request_id'] ) === '' )
		{
			$payload['check_request_id'] = bin2hex( random_bytes( 16 ) );
		}

		return $payload;
	}

	protected static function preparePayload( array $payload ): array
	{
		$key = trim( Settings::i()->ff_api_key );
		$out = array_merge(
			[
				'domain' => static::forumDomain(),
				'platform' => static::PLATFORM,
			],
			$payload
		);

		if ( $key !== '' )
		{
			$out['api_key'] = $key;
		}

		return $out;
	}

	protected static function persistIdentity( array $response, string $usedBase = '' ): void
	{
		$updates = [];

		if ( !empty( $response['api_key'] ) && $response['api_key'] !== Settings::i()->ff_api_key )
		{
			$updates['ff_api_key'] = (string) $response['api_key'];
		}

		if ( !empty( $response['site_id'] ) && $response['site_id'] !== Settings::i()->ff_site_id )
		{
			$updates['ff_site_id'] = (string) $response['site_id'];
		}

		if ( $updates )
		{
			Settings::i()->changeValues( $updates );
		}

		$state = static::loadEndpointState();
		$keyType = isset( $response['key_type'] ) ? (string) $response['key_type'] : '';
		$apiKey = isset( $response['api_key'] ) ? (string) $response['api_key'] : '';
		\FfApiResilience::applyOfflineBootstrapRouting( $response, $state, $usedBase );
		static::saveEndpointState( $state );
		if ( \FfApiResilience::isOfflineBootstrapKey( $apiKey, $keyType !== '' ? $keyType : NULL ) )
		{
			Log::log( 'Forum Fortress: control plane unavailable; using temporary regional key', 'forumfortress' );
		}
	}

	protected static function loadEndpointState(): array
	{
		$raw = trim( (string) Settings::i()->ff_endpoint_state );
		if ( $raw === '' )
		{
			return [];
		}

		$data = json_decode( $raw, TRUE );

		return is_array( $data ) ? $data : [];
	}

	protected static function saveEndpointState( array $state ): void
	{
		ksort( $state );
		$encoded = json_encode( $state, JSON_UNESCAPED_SLASHES );
		if ( !is_string( $encoded ) )
		{
			return;
		}

		if ( (string) Settings::i()->ff_endpoint_state === $encoded )
		{
			return;
		}

		Settings::i()->changeValues( [ 'ff_endpoint_state' => $encoded ] );
	}

	public static function endpointStateSummary(): array
	{
		$state = static::loadEndpointState();
		$lastRespondedNode = trim( (string) ( $state['last_responded_node'] ?? '' ) );
		$lastRespondedBase = trim( (string) ( $state['last_responded'] ?? '' ) );

		return [
			'preferred' => (string) ( $state['preferred'] ?? static::baseUrl() ),
			'last_responded' => $lastRespondedNode !== '' ? $lastRespondedNode : $lastRespondedBase,
			'endpoints_count' => is_array( $state['endpoints'] ?? NULL ) ? count( $state['endpoints'] ) : 1,
			'last_health_at' => (int) ( $state['last_health_at'] ?? 0 ),
			'last_site_ping_at' => (int) ( $state['last_site_ping_at'] ?? 0 ),
			'preferred_missing' => trim( (string) ( $state['preferred_missing'] ?? '' ) ),
		];
	}

	protected static function edgesHealthyForCheckTraffic( array $state ): bool
	{
		$healthMs = is_array( $state['health_ms'] ?? NULL ) ? $state['health_ms'] : [];
		$endpointMeta = is_array( $state['endpoint_meta'] ?? NULL ) ? $state['endpoint_meta'] : [];

		return \FfApiResilience::edgesHealthyForCheckTraffic(
			$healthMs,
			$endpointMeta,
			static function ( string $base, ?string $role ): bool {
				return static::isCatalogBackupEndpointUrl( $base, $role );
			}
		);
	}

	public static function endpointHealthDisplayLabel( string $endpointUrl, ?array $state = NULL ): string
	{
		$state = $state ?? static::loadEndpointState();
		$endpointUrl = static::normaliseBaseUrl( $endpointUrl );
		if ( $endpointUrl === '' )
		{
			return 'unreachable';
		}
		$healthMs = is_array( $state['health_ms'] ?? NULL ) ? $state['health_ms'] : [];
		$ms = array_key_exists( $endpointUrl, $healthMs ) ? $healthMs[ $endpointUrl ] : NULL;
		if ( is_int( $ms ) && $ms >= 0 )
		{
			return $ms . ' ms';
		}
		$endpointMeta = is_array( $state['endpoint_meta'] ?? NULL ) ? $state['endpoint_meta'] : [];
		$meta = isset( $endpointMeta[ $endpointUrl ] ) && is_array( $endpointMeta[ $endpointUrl ] ) ? $endpointMeta[ $endpointUrl ] : [];
		$role = isset( $meta['role'] ) ? (string) $meta['role'] : NULL;
		if ( !static::shouldProbeEndpointHealth( $endpointUrl, $state ) )
		{
			if ( static::isSharedApiRoundRobinBase( $endpointUrl ) )
			{
				return 'shared route';
			}
			if ( static::isCatalogBackupEndpointUrl( $endpointUrl, $role ) )
			{
				if ( static::edgesHealthyForCheckTraffic( $state ) )
				{
					if ( !empty( $meta['check_ready'] ) )
					{
						return 'standby (check-ready backup)';
					}

					return 'standby (not used for checks)';
				}

				return 'standby (backup)';
			}

			return 'not probed';
		}

		return 'unreachable';
	}

	/**
	 * @return list<array{endpoint: string, latency: string, is_preferred: bool}>
	 */
	public static function buildEndpointLatencyRows(): array
	{
		$state = static::loadEndpointState();
		$summary = static::endpointStateSummary();
		$preferred = static::normaliseBaseUrl( (string) ( $summary['preferred'] ?? '' ) );
		$endpointList = is_array( $state['endpoints'] ?? NULL ) ? $state['endpoints'] : [];
		$healthMs = is_array( $state['health_ms'] ?? NULL ) ? $state['health_ms'] : [];
		$displayTargets = array_values( array_unique( array_merge( $endpointList, array_keys( $healthMs ) ) ) );
		sort( $displayTargets );
		$rows = [];
		foreach ( $displayTargets as $endpoint )
		{
			$endpointUrl = (string) $endpoint;
			$rows[] = [
				'endpoint' => $endpointUrl,
				'latency' => static::endpointHealthDisplayLabel( $endpointUrl, $state ),
				'is_preferred' => $endpointUrl === $preferred,
			];
		}

		return $rows;
	}

	public static function refreshEndpointCatalogAndHealth( bool $force = FALSE ): void
	{
		if ( !static::isEnabled() )
		{
			return;
		}

		$manual = static::normaliseBaseUrl( static::baseUrl() );
		if ( $manual === '' )
		{
			return;
		}

		$state = static::loadEndpointState();
		$now = time();
		$refreshRequestedAt = (int) ( $state['refresh_requested_at'] ?? 0 );
		$lastHealthAt = (int) ( $state['last_health_at'] ?? 0 );
		if (
			!$force
			&& $refreshRequestedAt > 0
			&& $refreshRequestedAt > $lastHealthAt
			&& ( $now - $refreshRequestedAt ) >= self::ENDPOINT_REFRESH_REQUEST_MAX_DELAY_SECONDS
		)
		{
			$force = TRUE;
		}
		$forceHealthRefresh = FALSE;
		if ( $force )
		{
			$state['catalog_fetched_at'] = 0;
			static::invalidateEndpointHealthState( $state );
			$state['refresh_requested_at'] = 0;
			$forceHealthRefresh = TRUE;
		}
		$previousEndpoints = is_array( $state['endpoints'] ?? NULL ) ? $state['endpoints'] : [];

		$dayKey = gmdate( 'Y-m-d', $now );

		if ( \FfApiResilience::isEndpointCatalogStale( $state ) )
		{
			if ( !static::fetchNodeEndpointsCatalog( $force ) )
			{
				$state['catalog_fetched_at'] = $now;
				$state['endpoints'] = [ $manual ];
			}
			else
			{
				$state = static::loadEndpointState();
				if ( $forceHealthRefresh )
				{
					static::invalidateEndpointHealthState( $state );
				}
			}
		}

		$list = $state['endpoints'] ?? [];
		if ( !is_array( $list ) )
		{
			$list = [];
		}
		$state['endpoints'] = static::normaliseAndSanitiseEndpoints( $list, $manual );
		$preferredOverride = static::preferredBaseOverride();
		if ( $preferredOverride !== '' )
		{
			$withOverride = is_array( $state['endpoints'] ) ? $state['endpoints'] : [];
			$withOverride[] = $preferredOverride;
			$state['endpoints'] = static::normaliseAndSanitiseEndpoints( $withOverride, $manual );
		}
		if ( !$state['endpoints'] )
		{
			$state['endpoints'] = [ $manual ];
		}
		if (
			!$forceHealthRefresh
			&& static::endpointCatalogChanged( $previousEndpoints, is_array( $state['endpoints'] ) ? $state['endpoints'] : [] )
		)
		{
			static::invalidateEndpointHealthState( $state );
		}

		$lastHealth = (int) ( $state['last_health_at'] ?? 0 );
		$healthDay = (string) ( $state['health_day'] ?? '' );
		$healthRefreshInterval = static::endpointHealthRefreshIntervalSeconds( $state );
		if ( $healthDay !== $dayKey || ( $now - $lastHealth ) > $healthRefreshInterval )
		{
			$latencies = [];
			$candidates = is_array( $state['endpoints'] ) ? $state['endpoints'] : [];
			$candidates = array_values( array_unique( array_map( static fn( $u ) => static::normaliseBaseUrl( (string) $u ), $candidates ) ) );
			$candidates = array_values( array_filter( $candidates, static fn( $u ) => $u !== '' ) );
			if ( $manual !== '' && !in_array( $manual, $candidates, TRUE ) && static::shouldProbeEndpointHealth( $manual, $state ) )
			{
				$candidates[] = $manual;
			}
			$endpointMeta = is_array( $state['endpoint_meta'] ?? NULL ) ? $state['endpoint_meta'] : [];
			$probeHealthMs = [];
			foreach ( is_array( $state['health_ms'] ?? NULL ) ? $state['health_ms'] : [] as $probeBase => $probeMs )
			{
				$normalisedProbeBase = static::normaliseBaseUrl( (string) $probeBase );
				if ( $normalisedProbeBase === '' )
				{
					continue;
				}
				$probeHealthMs[ $normalisedProbeBase ] = is_int( $probeMs ) ? $probeMs : NULL;
			}
			$isBackup = function ( string $base, ?string $role ): bool {
				return static::isCatalogBackupEndpointUrl( $base, $role );
			};
			$candidates = \FfApiResilience::sortBasesByHealthyLatency( $candidates, $probeHealthMs, $endpointMeta, $isBackup );
			foreach ( $candidates as $base )
			{
				if ( !static::shouldProbeEndpointHealth( $base, $state ) )
				{
					continue;
				}
				$t0 = microtime( TRUE );
				$hr = static::rawRequest( 'GET', $base, '/health', [], self::CONNECTION_TEST_TIMEOUT_SECONDS );
				$ms = NULL;
				if ( ( $hr['status'] ?? 0 ) >= 200 && ( $hr['status'] ?? 0 ) < 300 )
				{
					$ms = (int) round( ( microtime( TRUE ) - $t0 ) * 1000 );
					$cr = static::rawRequest( 'GET', $base, '/v1/check-ready', [], self::CONNECTION_TEST_TIMEOUT_SECONDS );
					$liveReady = ( $cr['status'] ?? 0 ) >= 200 && ( $cr['status'] ?? 0 ) < 300;
					if ( !isset( $endpointMeta[ $base ] ) || !is_array( $endpointMeta[ $base ] ) )
					{
						$endpointMeta[ $base ] = [];
					}
					$endpointMeta[ $base ]['check_ready'] = $liveReady;
					if ( !$liveReady )
					{
						$ms = NULL;
					}
				}
				$latencies[ $base ] = $ms;
			}
			$state['endpoint_meta'] = $endpointMeta;
			$best = \FfApiResilience::resolvePreferredHealthyBase(
				array_keys( $latencies ),
				$latencies,
				$endpointMeta,
				$isBackup,
				$manual
			);
			$bestMs = \FfApiResilience::isHealthyLatency( $latencies[ $best ] ?? NULL )
				? (int) $latencies[ $best ]
				: 999999;
			$hasHealthy = FALSE;
			foreach ( $latencies as $ms )
			{
				if ( \FfApiResilience::isHealthyLatency( $ms ) )
				{
					$hasHealthy = TRUE;
					break;
				}
			}
			$state['last_health_at'] = $now;
			$state['health_day'] = $dayKey;
			$state['health_ms'] = $latencies;
			$state['preferred'] = $best;
			$wasSlow = !empty( $state['slow_health_mode'] );
			$isSlow = !$hasHealthy
				|| $bestMs > self::ENDPOINT_HEALTH_SLOW_TRIGGER_MS
				|| ( $wasSlow && $bestMs > self::ENDPOINT_HEALTH_RECOVERY_MS );
			$state['slow_health_mode'] = $isSlow;
			$state['best_latency_ms'] = $hasHealthy ? (int) $bestMs : 0;
			$state['refresh_requested_at'] = 0;
		}
		if ( $preferredOverride !== '' )
		{
			$state['preferred'] = $preferredOverride;
		}

		static::saveEndpointState( $state );
	}

	/**
	 * @param list<mixed> $endpoints
	 * @return list<string>
	 */
	protected static function normaliseAndSanitiseEndpoints( array $endpoints, string $manualBase ): array
	{
		$manualBase = static::normaliseBaseUrl( $manualBase );
		$normalised = array_values( array_unique( array_map( static fn( $u ) => static::normaliseBaseUrl( (string) $u ), $endpoints ) ) );
		$normalised = array_values( array_filter( $normalised, static fn( $u ) => $u !== '' ) );
		if ( $manualBase !== '' && count( $normalised ) > 1 )
		{
			$normalised = array_values( array_filter( $normalised, static fn( $u ) => $u !== $manualBase ) );
		}
		if ( !$normalised && $manualBase !== '' )
		{
			$normalised = [ $manualBase ];
		}
		return $normalised;
	}

	/**
	 * Routing: catalog from control; health probes edges only; checks on check_ready edges;
	 * control for checks only when control_check_fallback or no healthy edge. api.ffapi.net is
	 * legacy shared DNS (edges proxy health/catalog); do not treat control as down when two edges
	 * are healthy (see edgesHealthyForCheckTraffic / shouldProbeEndpointHealth).
	 */
	protected static function baseUrlMayServeCheckTraffic( string $baseUrl ): bool
	{
		$baseUrl = static::normaliseBaseUrl( $baseUrl );
		if ( $baseUrl === '' )
		{
			return FALSE;
		}
		$control = static::normaliseBaseUrl( static::controlBaseUrl() );
		if ( $control !== '' && $baseUrl === $control )
		{
			$state = static::loadEndpointState();
			return !empty( $state['control_check_fallback'] ) || !static::edgesHealthyForCheckTraffic( $state );
		}
		$manual = static::normaliseBaseUrl( static::baseUrl() );
		if ( $manual !== '' && $baseUrl === $manual )
		{
			$host = parse_url( $manual, PHP_URL_HOST );
			if ( is_string( $host ) && strpos( strtolower( $host ), 'api.' ) === 0 )
			{
				return FALSE;
			}
		}
		return TRUE;
	}

	protected static function getOrderedBasesForRequests( ?string $requestPath = NULL ): array
	{
		$manual = static::normaliseBaseUrl( static::baseUrl() );
		if ( $manual === '' )
		{
			return [];
		}
		$state = static::loadEndpointState();
		if (
			is_string( $requestPath )
			&& strpos( $requestPath, '/v1/check' ) === 0
			&& static::isOfflineApiKey()
		)
		{
			$pinned = \FfApiResilience::offlinePinnedCheckBases( $state );
			if ( $pinned )
			{
				return $pinned;
			}
		}
		if ( is_string( $requestPath ) && \FfApiResilience::isStrictSupernodeSyncPath( $requestPath ) )
		{
			$bases = \FfApiResilience::moderationSyncBasesOrdered(
				static::hotFailoverApiBaseUrl(),
				static::controlBaseUrl()
			);
			if ( $bases )
			{
				return $bases;
			}
		}
		$refreshRequestedAt = (int) ( $state['refresh_requested_at'] ?? 0 );
		$lastHealthAt = (int) ( $state['last_health_at'] ?? 0 );
		if (
			$refreshRequestedAt > 0
			&& $refreshRequestedAt > $lastHealthAt
			&& ( time() - $refreshRequestedAt ) >= self::ENDPOINT_REFRESH_REQUEST_MAX_DELAY_SECONDS
		)
		{
			try
			{
				static::refreshEndpointCatalogAndHealth( TRUE );
				$state = static::loadEndpointState();
			}
			catch ( Throwable $e )
			{
			}
		}
		$endpoints = $state['endpoints'] ?? NULL;
		if ( !is_array( $endpoints ) || !$endpoints )
		{
			$endpoints = [ $manual ];
		}
		$endpoints = array_values( array_unique( array_map( static fn( $u ) => static::normaliseBaseUrl( (string) $u ), $endpoints ) ) );
		$endpoints = array_values( array_filter( $endpoints, static fn( $u ) => $u !== '' ) );
		$endpointMeta = is_array( $state['endpoint_meta'] ?? NULL ) ? $state['endpoint_meta'] : [];
		$healthMs = is_array( $state['health_ms'] ?? NULL ) ? $state['health_ms'] : [];
		$latencyByBase = [];
		foreach ( $healthMs as $base => $ms )
		{
			$normalisedBase = static::normaliseBaseUrl( (string) $base );
			if ( $normalisedBase === '' )
			{
				continue;
			}
			$latencyByBase[ $normalisedBase ] = is_int( $ms ) ? $ms : NULL;
		}
		$isBackup = function ( string $base, ?string $role ): bool {
			return static::isCatalogBackupEndpointUrl( $base, $role );
		};
		$sorted = \FfApiResilience::sortBasesByHealthyLatency( $endpoints, $latencyByBase, $endpointMeta, $isBackup );
		$preferredOverride = static::preferredBaseOverride();
		$routingFallback = in_array( $manual, $endpoints, TRUE ) ? $manual : ( $sorted[0] ?? $manual );
		$computedPreferred = \FfApiResilience::resolvePreferredHealthyBase(
			$sorted,
			$latencyByBase,
			$endpointMeta,
			$isBackup,
			$routingFallback
		);
		$preferred = $preferredOverride !== '' ? $preferredOverride : $computedPreferred;
		$preferredCandidate = $preferred;
		if ( $preferred === '' || !in_array( $preferred, $endpoints, TRUE ) )
		{
			$state['preferred_missing'] = $preferredCandidate;
			$state['preferred_missing_at'] = time();
			$preferred = $sorted[0] ?? $manual;
			if ( $preferredOverride === '' )
			{
				$state['preferred'] = $preferred;
			}
			static::saveEndpointState( $state );
		}
		else if ( isset( $state['preferred_missing'] ) )
		{
			unset( $state['preferred_missing'] );
			unset( $state['preferred_missing_at'] );
			static::saveEndpointState( $state );
		}

		$out = \FfApiResilience::uniqueOrderedBases(
			$preferred !== '' ? [ $preferred ] : [],
			$sorted
		);
		if ( !in_array( $manual, $out, TRUE ) )
		{
			$out[] = $manual;
		}
		if ( is_string( $requestPath ) && mb_strpos( $requestPath, '/v1/check' ) === 0 )
		{
			$out = array_values( array_filter( $out, function ( $b ) use ( $endpointMeta, $latencyByBase ) {
				$base = (string) $b;
				if ( !static::baseUrlMayServeCheckTraffic( $base ) )
				{
					return FALSE;
				}
				$ms = array_key_exists( $base, $latencyByBase ) && is_int( $latencyByBase[ $base ] )
					? $latencyByBase[ $base ]
					: NULL;

				return \FfApiResilience::endpointEligibleForCheckTraffic( $endpointMeta, $base, $ms );
			} ) );
			$control = static::normaliseBaseUrl( static::controlBaseUrl() );
			if ( $control !== '' && static::baseUrlMayServeCheckTraffic( $control ) && !in_array( $control, $out, TRUE ) )
			{
				$out[] = $control;
			}
			$out = \FfApiResilience::orderCheckBasesControlLast( $out, $control );
		}

		return $out;
	}

	protected static function markPreferredBaseAfterFailover( string $baseUrl ): void
	{
		$baseUrl = static::normaliseBaseUrl( $baseUrl );
		if ( $baseUrl === '' )
		{
			return;
		}
		if ( static::preferredBaseOverride() !== '' )
		{
			return;
		}
		$state = static::loadEndpointState();
		$state['preferred'] = $baseUrl;
		$state['failover_at'] = time();
		static::saveEndpointState( $state );
	}

	protected static function shouldFailoverOnIntermittentStatus( int $status, string $path ): bool
	{
		if ( !in_array( $status, [ 401, 404 ], TRUE ) )
		{
			return FALSE;
		}
		return in_array(
			$path,
			[
				'/v1/site/portal',
				'/v1/site/status',
				'/v1/forum/stats',
				'/v1/moderation-queue/sync',
				'/v1/moderation-actions/pull',
				'/v1/moderation-actions/ack',
				'/v1/plugin-release',
			],
			TRUE
		);
	}

	protected static function rawRequest( string $method, string $baseUrl, string $path, array $body, int $timeout ): array
	{
		$out = [ 'status' => 0, 'body' => '', 'data' => NULL, 'error' => NULL, 'node_header' => '' ];
		$baseUrl = static::normaliseBaseUrl( $baseUrl );
		if ( $baseUrl === '' )
		{
			$out['error'] = 'empty_base';
			return $out;
		}

		try
		{
			$url = Url::external( $baseUrl . $path );

			if ( strtoupper( $method ) === 'GET' && $body )
			{
				$url = $url->setQueryString( $body );
			}

			$curl = $url->request( $timeout );
			$headers = [ 'Accept' => 'application/json' ];

			if ( strtoupper( $method ) !== 'GET' )
			{
				$headers['Content-Type'] = 'application/json';
			}

			$curl->setHeaders( $headers );

			$resp = strtoupper( $method ) === 'GET'
				? $curl->get()
				: $curl->post( json_encode( $body ) );
			$out['status'] = (int) $resp->httpResponseCode;
			$out['node_header'] = static::extractNodeHeader( $resp );
			$out['body'] = (string) $resp;
			if ( $out['body'] !== '' )
			{
				$decoded = json_decode( $out['body'], TRUE );
				$out['data'] = is_array( $decoded ) ? $decoded : NULL;
			}
		}
		catch ( Throwable $e )
		{
			$out['error'] = $e->getMessage();
		}

		return $out;
	}

	/** Bootstrap, catalog, capabilities, plugin-release: control, hot api.ffapi.net, then edges. */
	protected static function requestFromControlPlane( string $method, string $path, array $body, ?int $timeoutOverride = NULL ): ?array
	{
		if ( !static::isEnabled() )
		{
			return NULL;
		}
		$bases = static::controlPlaneRequestBases();
		if ( !$bases )
		{
			$manual = static::normaliseBaseUrl( static::baseUrl() );
			if ( $manual !== '' )
			{
				$bases = [ $manual ];
			}
		}
		if ( !$bases )
		{
			return NULL;
		}
		$timeout = $timeoutOverride ?? max( 1, (int) Settings::i()->ff_timeout );
		foreach ( $bases as $base )
		{
			$res = static::rawRequest( $method, $base, $path, $body, $timeout );
			$status = (int) ( $res['status'] ?? 0 );
			$data = $res['data'] ?? NULL;
			if ( $status >= 200 && $status < 300 && is_array( $data ) )
			{
				static::persistIdentity( $data );
				$state = static::loadEndpointState();
				$state['last_responded'] = $base;
				$state['last_responded_node'] = trim( (string) ( $res['node_header'] ?? '' ) );
				$state['last_response_at'] = time();
				static::saveEndpointState( $state );
				if ( !static::$moderationSyncInProgress && mb_strpos( $path, '/v1/moderation-' ) !== 0 )
				{
					static::runModerationSyncCycle( FALSE );
				}
				static::maybeRefreshEndpointCatalogAfterCheckIn( $path );
				return $data;
			}
			if ( Settings::i()->ff_debug_log )
			{
				Log::debug( [ 'ff_path' => $path, 'status' => $status, 'base' => $base, 'ff_control_plane' => TRUE ], 'forumfortress' );
			}
		}

		return NULL;
	}

	public static function request( string $method, string $path, array $body, ?int $timeoutOverride = NULL ): ?array
	{
		$result = static::requestPass( $method, $path, $body, $timeoutOverride, TRUE );
		if ( $result !== NULL )
		{
			return $result;
		}
		try
		{
			static::refreshEndpointCatalogAndHealth( TRUE );
		}
		catch ( Throwable $e )
		{
		}

		return static::requestPass( $method, $path, $body, $timeoutOverride, FALSE );
	}

	protected static function requestPass( string $method, string $path, array $body, ?int $timeoutOverride, bool $allowRebootstrap ): ?array
	{
		$bases = static::getOrderedBasesForRequests( $path );
		if ( !$bases )
		{
			return NULL;
		}
		$timeout = $timeoutOverride ?? max( 1, (int) Settings::i()->ff_timeout );
		$hotApi = static::hotFailoverApiBaseUrl();
		$tried = [];

		foreach ( $bases as $index => $base )
		{
			$base = static::normaliseBaseUrl( (string) $base );
			if ( $base === '' || in_array( $base, $tried, TRUE ) )
			{
				continue;
			}
			$tried[] = $base;

			$res = static::requestOnBaseWithRetry( $method, $base, $path, $body, $timeout, $allowRebootstrap && $index === 0 );
			$status = (int) ( $res['status'] ?? 0 );
			$data = $res['data'] ?? NULL;
			if ( $status >= 200 && $status < 300 && is_array( $data ) )
			{
				static::persistIdentity( $data );
				$state = static::loadEndpointState();
				$state['last_responded'] = $base;
				$state['last_responded_node'] = trim( (string) ( $res['node_header'] ?? '' ) );
				$state['last_response_at'] = time();
				static::saveEndpointState( $state );
				if ( $index > 0 )
				{
					static::markPreferredBaseAfterFailover( $base );
				}
				static::maybeRefreshEndpointCatalogAfterCheckIn( $path );
				return $data;
			}

			$canFailover = $status === 0
				|| $status === 520
				|| ( $status >= 502 && $status <= 504 )
				|| static::shouldFailoverOnIntermittentStatus( $status, (string) $path );
			if ( $canFailover )
			{
				static::markEndpointRefreshRequested();
			}
			if ( !$canFailover )
			{
				if ( Settings::i()->ff_debug_log )
				{
					Log::debug( [ 'ff_path' => $path, 'status' => $status, 'base' => $base, 'body' => (string) ( $res['body'] ?? '' ) ], 'forumfortress' );
				}
				return NULL;
			}

			if ( $hotApi !== '' && !in_array( $hotApi, $tried, TRUE ) )
			{
				$tried[] = $hotApi;
				$hotRes = static::requestOnBaseWithRetry( $method, $hotApi, $path, $body, $timeout, FALSE );
				$hotStatus = (int) ( $hotRes['status'] ?? 0 );
				$hotData = $hotRes['data'] ?? NULL;
				if ( $hotStatus >= 200 && $hotStatus < 300 && is_array( $hotData ) )
				{
					static::persistIdentity( $hotData );
					$state = static::loadEndpointState();
					$state['last_responded'] = $hotApi;
					$state['last_responded_node'] = trim( (string) ( $hotRes['node_header'] ?? '' ) );
					$state['last_response_at'] = time();
					static::saveEndpointState( $state );
					static::markPreferredBaseAfterFailover( $hotApi );
					static::maybeRefreshEndpointCatalogAfterCheckIn( $path );
					return $hotData;
				}
				$hotCanFailover = $hotStatus === 0
					|| $hotStatus === 520
					|| ( $hotStatus >= 502 && $hotStatus <= 504 )
					|| static::shouldFailoverOnIntermittentStatus( $hotStatus, (string) $path );
				if ( !$hotCanFailover )
				{
					return NULL;
				}
			}
		}

		return static::requestControlCheckFallbackAfterEdges(
			$method,
			$path,
			$body,
			$tried,
			$timeoutOverride,
			$allowRebootstrap
		);
	}

	/**
	 * @param list<string> $tried
	 */
	protected static function requestControlCheckFallbackAfterEdges(
		string $method,
		string $path,
		array $body,
		array $tried,
		?int $timeoutOverride,
		bool $allowRebootstrap
	): ?array {
		if ( mb_strpos( $path, '/v1/check' ) !== 0 )
		{
			return NULL;
		}
		$state = static::loadEndpointState();
		if ( empty( $state['control_check_fallback'] ) )
		{
			return NULL;
		}
		$control = static::normaliseBaseUrl( static::controlBaseUrl() );
		if ( $control === '' || in_array( $control, $tried, TRUE ) )
		{
			return NULL;
		}
		if ( !static::baseUrlMayServeCheckTraffic( $control ) )
		{
			return NULL;
		}
		$timeout = $timeoutOverride ?? max( 1, (int) Settings::i()->ff_timeout );
		$res = static::requestOnBaseWithRetry( $method, $control, $path, $body, $timeout, FALSE );
		$status = (int) ( $res['status'] ?? 0 );
		$data = $res['data'] ?? NULL;
		if ( $status >= 200 && $status < 300 && is_array( $data ) )
		{
			static::persistIdentity( $data );
			$state = static::loadEndpointState();
			$state['last_responded'] = $control;
			$state['last_responded_node'] = trim( (string) ( $res['node_header'] ?? '' ) );
			$state['last_response_at'] = time();
			static::saveEndpointState( $state );
			static::maybeRefreshEndpointCatalogAfterCheckIn( $path );

			return $data;
		}

		return NULL;
	}

	protected static function requestOnBaseWithRetry(
		string $method,
		string $base,
		string $path,
		array $body,
		int $timeout,
		bool $allowRebootstrap,
		bool $timeoutRetried = FALSE
	): array {
		$res = static::rawRequest( $method, $base, $path, $body, $timeout );
		$status = (int) ( $res['status'] ?? 0 );
		$responseBody = (string) ( $res['body'] ?? '' );
		$error = (string) ( $res['error'] ?? '' );
		if ( $error !== '' && static::isTimeoutMessage( $error ) )
		{
			static::$lastCheckHadTimeout = TRUE;
		}
		if ( $error !== '' && !$timeoutRetried && static::isTimeoutMessage( $error ) )
		{
			return static::requestOnBaseWithRetry( $method, $base, $path, $body, $timeout, $allowRebootstrap, TRUE );
		}
		if ( $error !== '' && static::isTimeoutMessage( $error ) )
		{
			static::markEndpointRefreshRequested();
		}
		if ( $allowRebootstrap && $status === 401 && static::shouldRebootstrap( $status, $responseBody, $path ) )
		{
			static::resetIdentity();
			$bootstrap = static::bootstrapIfNeeded();
			if ( is_array( $bootstrap ) && !empty( $bootstrap['api_key'] ) )
			{
				// Swap *all* identity fields the retry payload carries. The
				// freshly-minted credentials have a new site_id, so leaving
				// stale values here causes the server to return 409 stale_site.
				$retried = $body;
				if ( array_key_exists( 'api_key', $retried ) )
				{
					$retried['api_key'] = trim( (string) Settings::i()->ff_api_key );
				}
				if ( array_key_exists( 'site_id', $retried ) )
				{
					$retried['site_id'] = trim( (string) Settings::i()->ff_site_id );
				}
				return static::requestOnBaseWithRetry( $method, $base, $path, $retried, $timeout, FALSE, $timeoutRetried );
			}
		}
		return $res;
	}

	protected static function shouldRebootstrap( int $status, string $body, string $path ): bool
	{
		if ( $status !== 401 )
		{
			return FALSE;
		}
		if ( $path === '/v1/site/bootstrap' || trim( (string) Settings::i()->ff_api_key ) === '' )
		{
			return FALSE;
		}
		$data = json_decode( $body, TRUE );
		if ( !is_array( $data ) )
		{
			return FALSE;
		}
		// Backend can emit either a flat body ({"error": "...", "message": "..."},
		// produced by our HTTPException handler) or the FastAPI default
		// ({"detail": {...}} or {"detail": "..."}). Match both shapes.
		$candidates = [];
		if ( isset( $data['error'] ) )
		{
			$candidates[] = strtolower( trim( (string) $data['error'] ) );
		}
		$detail = $data['detail'] ?? NULL;
		if ( is_array( $detail ) && isset( $detail['error'] ) )
		{
			$candidates[] = strtolower( trim( (string) $detail['error'] ) );
		}
		foreach ( $candidates as $candidate )
		{
			if ( in_array( $candidate, [ 'invalid_key', 'unknown_site', 'invalid_key_format' ], TRUE ) )
			{
				return TRUE;
			}
		}
		$plainDetail = is_string( $detail ) ? strtolower( trim( $detail ) ) : '';
		return in_array( $plainDetail, [ 'invalid api key', 'site not found' ], TRUE );
	}

	protected static function resetIdentity(): void
	{
		Settings::i()->changeValues( [
			'ff_api_key' => '',
			'ff_site_id' => '',
		] );
	}

	protected static function isTimeoutMessage( string $message ): bool
	{
		$text = strtolower( trim( $message ) );
		if ( $text === '' )
		{
			return FALSE;
		}
		return strpos( $text, 'timed out' ) !== FALSE
			|| strpos( $text, 'timeout' ) !== FALSE
			|| strpos( $text, 'cURL error 28' ) !== FALSE;
	}

	protected static function getModerationSyncIntervalSeconds( array $state ): int
	{
		if ( (int) ( $state['moderation_pending_actions'] ?? 0 ) > 0 )
		{
			return 60;
		}

		return self::MODERATION_SYNC_SECONDS;
	}

	protected static function getBlockRejectAction(): string
	{
		return 'reject';
	}

	protected static function endpointHealthRefreshIntervalSeconds( array $state ): int
	{
		$bestLatency = (int) ( $state['best_latency_ms'] ?? 0 );
		$slowMode = !empty( $state['slow_health_mode'] );
		if ( $slowMode )
		{
			if ( $bestLatency > 0 && $bestLatency <= self::ENDPOINT_HEALTH_RECOVERY_MS )
			{
				return self::ENDPOINT_HEALTH_REFRESH_SECONDS;
			}
			return self::ENDPOINT_HEALTH_DEGRADED_REFRESH_SECONDS;
		}
		if ( $bestLatency > self::ENDPOINT_HEALTH_SLOW_TRIGGER_MS )
		{
			return self::ENDPOINT_HEALTH_DEGRADED_REFRESH_SECONDS;
		}
		return self::ENDPOINT_HEALTH_REFRESH_SECONDS;
	}

	protected static function refreshPlanCacheIfStale( bool $force ): void
	{
		$state = static::loadEndpointState();
		$last = (int) ( $state['plan_checked_at'] ?? 0 );
		if ( !$force && $last > 0 && ( time() - $last ) < self::PLAN_REFRESH_SECONDS )
		{
			return;
		}
		$status = static::siteStatus();
		$state['plan_checked_at'] = time();
		if ( is_array( $status ) && !empty( $status['plan'] ) )
		{
			$state['plan_name'] = strtolower( trim( (string) $status['plan'] ) );
		}
		static::saveEndpointState( $state );
	}

	protected static function markEndpointRefreshRequested(): void
	{
		$state = static::loadEndpointState();
		$state['refresh_requested_at'] = time();
		static::saveEndpointState( $state );
	}

	protected static function shouldRunDailyTask( string $key ): bool
	{
		$state = static::loadEndpointState();
		$last = (int) ( $state[ $key ] ?? 0 );
		return $last <= 0 || ( time() - $last ) >= 86400;
	}

	protected static function markDailyTaskRun( string $key ): void
	{
		$state = static::loadEndpointState();
		$state[ $key ] = time();
		static::saveEndpointState( $state );
	}

	public static function emailDomain( string $email ): ?string
	{
		if ( $email === '' || mb_strpos( $email, '@' ) === FALSE )
		{
			return NULL;
		}

		$parts = explode( '@', $email, 2 );

		return isset( $parts[1] ) ? strtolower( trim( $parts[1] ) ) : NULL;
	}

	public static function extractLinks( string $text ): array
	{
		if ( $text === '' )
		{
			return [];
		}

		preg_match_all( '#https?://[^\s<>"\']+#i', $text, $matches );
		$links = is_array( $matches[0] ?? NULL ) ? $matches[0] : [];

		return array_values( array_unique( array_map( 'strval', $links ) ) );
	}

	protected static function mapApprovalClassToType( string $class ): ?string
	{
		if ( $class === 'IPS\\forums\\Topic' )
		{
			return 'thread';
		}
		if ( $class === 'IPS\\forums\\Topic\\Post' )
		{
			return 'post';
		}
		if ( $class === 'IPS\\Member' )
		{
			return 'member';
		}

		return NULL;
	}

	protected static function extractNodeHeader( object $response ): string
	{
		try
		{
			foreach ( [ 'headers', 'httpHeaders' ] as $property )
			{
				if ( isset( $response->{$property} ) && is_array( $response->{$property} ) )
				{
					foreach ( $response->{$property} as $key => $value )
					{
						if ( strtolower( trim( (string) $key ) ) === 'x-forumfortress-node' )
						{
							return trim( is_array( $value ) ? (string) reset( $value ) : (string) $value );
						}
					}
				}
			}
		}
		catch ( Throwable $e )
		{
		}
		return '';
	}
}
