<?php
/**
 * Save-settings POST handler.
 *
 * Phase-8 slice 8c-3 Presentation-layer handler that drives the
 * `admin-post.php?action=affilun_save_settings` POST endpoint
 * documented in `specifications/admin-ui-spec.md` §13 / §13.10.
 * The handler is the canonical capability-and-nonce gate for
 * the §13 Settings Save action: every persisting code path
 * driven from the §13 admin form flows through {@see self::handle()},
 * which:
 *
 *   1. Re-checks the §3 admin-tier capability via the injected
 *      capability probe, mirroring the page-render closure
 *      installed by
 *      {@see \Affilun\Presentation\Admin\AdminServiceProvider}.
 *      Failure redirects with `affilun_status=forbidden`.
 *
 *   2. Verifies the §6.4 nonce posted under
 *      {@see SettingsFormToken::NONCE_FIELD} against
 *      {@see SettingsFormToken::NONCE_ACTION} via the injected
 *      verifier. The verifier closure receives `(action, value)`
 *      so the handler is self-contained — production wiring
 *      forwards to WordPress's `wp_verify_nonce()` (whose native
 *      argument order is `(value, action)`) verbatim. Failure
 *      redirects with `affilun_status=invalid_nonce`.
 *
 *   3. Parses the namespaced `affilun_settings` sub-array into a
 *      {@see \Affilun\Application\Settings\PluginSettings}
 *      via {@see SettingsFormParser::parse()}, falling back
 *      gracefully to the currently persisted snapshot for any
 *      malformed sub-section.
 *
 *   4. Persists through {@see SaveSettingsUseCase::execute()},
 *      which in turn applies the §6.3 rotating-input rule for
 *      empty provider-secret fields. Success redirects with
 *      `affilun_status=saved`; any {@see \Throwable} from the use
 *      case (typically a repository-layer failure) redirects
 *      with `affilun_status=error`. The throwable's message is NOT
 *      surfaced in the URL — §6.4 forbids leaking persistence-
 *      layer details to the admin notice surface.
 *
 * Production-vs-test redirector contract:
 *
 *   - The default redirector closure calls
 *     `wp_safe_redirect($url)` and then `exit;`. WordPress's own
 *     post-handlers behave the same way; without `exit` the
 *     `admin-post.php` action dispatcher would continue and
 *     emit a second response. Tests therefore MUST inject a
 *     custom recording redirector that captures the URL and
 *     returns without calling `exit;` so PHPUnit can complete
 *     the test process.
 *
 * Status-flash literals carried in the `affilun_status` query
 * parameter mirror {@see SettingsPageRenderer}'s switch:
 * `'saved'`, `'error'`, `'forbidden'`, `'invalid_nonce'`.
 *
 * Authorization invariants:
 *
 *   - The capability probe is invoked before the nonce check
 *     so a request with valid capabilities but a stale nonce
 *     produces `invalid_nonce` (not `forbidden`); a request
 *     without capabilities produces `forbidden` regardless of
 *     whether the nonce would have validated.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Application\Affiliate\OptionStoreAffiliateRoutingRepository;
use Affilun\Application\Audit\AuditEventDraft;
use Affilun\Application\Cache\RenderCache;
use Affilun\Application\Audit\AuditEventWriter;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Application\Settings\UseCase\SaveSettingsUseCase;
use Affilun\Presentation\Admin\AdminCapability;
use RuntimeException;
use Throwable;

/**
 * Drives the §13 Settings page's `admin-post.php?action=affilun_save_settings`
 * POST endpoint.
 *
 * See the file-level docblock for the cap/nonce/parse/save flow,
 * the production-vs-test redirector contract, and the
 * status-flash literal set carried in the `affilun_status` query
 * parameter.
 */
final class SaveSettingsHandler {

	private const YAHOO_PROVIDER_KEY = 'yahoo_shopping';

	private const YAHOO_VALUECOMMERCE_HTML_FIELD = 'valuecommerce_html';

	private const VALUECOMMERCE_HTML_MAX_BYTES = 20000;

	/**
	 * §13 settings repository. Read for the parser fallback
	 * snapshot; the actual persistence write is driven through
	 * {@see SaveSettingsUseCase::execute()}.
	 */
	private SettingsRepository $repository;

	/**
	 * §13.10 save use case applying the §6.3 rotating-input rule.
	 */
	private SaveSettingsUseCase $saveUseCase;

	/**
	 * Form parser converting the `$_POST['affilun_settings']` sub-
	 * array into a {@see \Affilun\Application\Settings\PluginSettings}.
	 */
	private SettingsFormParser $parser;

	/**
	 * Capability probe.
	 *
	 * Signature: `function (string $capability): bool`.
	 *
	 * @var callable
	 */
	private $capProbe;

	/**
	 * Nonce verifier.
	 *
	 * Signature: `function (string $action, string $nonceValue): bool`.
	 * Note that WordPress's native `wp_verify_nonce()` takes
	 * `(value, action)` — the default closure swaps the
	 * argument order so this handler can pass `(action, value)`
	 * consistently with {@see SettingsFormToken::validateNonce()}.
	 *
	 * @var callable
	 */
	private $nonceVerifier;

	/**
	 * Redirector.
	 *
	 * Signature: `function (string $url): void`. The production
	 * default calls `wp_safe_redirect($url); exit;`; tests MUST
	 * inject a recording closure that returns without calling
	 * `exit;`.
	 *
	 * @var callable
	 */
	private $redirector;

	/**
	 * Settings-page URL builder.
	 *
	 * Signature: `function (): string`. Returns the absolute
	 * URL of the §13 Settings admin page so the redirector can
	 * append the `affilun_status=...` query parameter.
	 *
	 * @var callable
	 */
	private $urlBuilder;

	/**
	 * Optional §10.1 audit-event writer.
	 *
	 * When non-null, every terminal outcome of {@see self::handle()}
	 * (`saved` / `error` / `forbidden` / `invalid_nonce`) emits a
	 * `configuration_change` audit row through this writer. When
	 * null (the default) the handler stays silent — pure-unit
	 * tests and bootstrap paths that pre-date the audit-writer
	 * port keep working without modification.
	 *
	 * Per the {@see AuditEventWriter} contract, audit emit
	 * failures are tolerated: a {@see RuntimeException} from
	 * `record()` is swallowed inside {@see self::emitAudit()} so
	 * losing one audit row never masks the underlying business
	 * outcome (saved / error / forbidden / invalid_nonce).
	 */
	private ?AuditEventWriter $auditWriter;

	/**
	 * Resolver for the WordPress user id of the current admin
	 * caller.
	 *
	 * Signature: `function (): ?int`. Returns the logged-in
	 * user's id when WordPress is loaded and a session is
	 * present; null otherwise. Used to populate the audit row's
	 * `actor_id` slot — when null, the row is recorded as a
	 * system action so the audit trail still contains the event.
	 *
	 * @var callable
	 */
	private $userIdResolver;

	/**
	 * Optional ASP affiliate-routing repository saved with the main settings form.
	 */
	private ?OptionStoreAffiliateRoutingRepository $affiliateRoutingRepository;

	/**
	 * Optional render cache flushed after settings changes that affect card HTML.
	 */
	private ?RenderCache $renderCache;

	/**
	 * Construct the handler with explicit collaborators and
	 * (optionally overridden) capability / nonce / redirector /
	 * URL-builder closures.
	 *
	 * Production wiring passes only the three non-optional
	 * collaborators; the four closures default to thin wrappers
	 * over WordPress core. Tests inject recording closures so
	 * the handler can be exercised without WordPress loaded.
	 *
	 * IMPORTANT: the default redirector closure calls
	 * `wp_safe_redirect($url)` AND `exit;`. Tests MUST inject a
	 * custom recording redirector that returns without calling
	 * `exit;` so PHPUnit can complete the test process.
	 *
	 * @param SettingsRepository                         $repository     §13 settings repository.
	 * @param SaveSettingsUseCase                        $saveUseCase    §13.10 save use case.
	 * @param SettingsFormParser                         $parser         §13 form parser.
	 * @param callable|null                              $capProbe       Capability probe override.
	 * @param callable|null                              $nonceVerifier  Nonce-verifier override.
	 * @param callable|null                              $redirector     Redirector override.
	 * @param callable|null                              $urlBuilder     Settings-page URL builder override.
	 * @param AuditEventWriter|null                      $auditWriter                Optional §10.1 audit writer.
	 * @param callable|null                              $userIdResolver             Optional current-user id resolver.
	 * @param OptionStoreAffiliateRoutingRepository|null $affiliateRoutingRepository Optional ASP routing repository.
	 * @param RenderCache|null                           $renderCache                Optional render cache to flush after save.
	 *
	 * @phpstan-param (callable(string): bool)|null         $capProbe
	 * @phpstan-param (callable(string, string): bool)|null $nonceVerifier
	 * @phpstan-param (callable(string): void)|null         $redirector
	 * @phpstan-param (callable(): string)|null             $urlBuilder
	 * @phpstan-param (callable(): ?int)|null               $userIdResolver
	 */
	public function __construct(
		SettingsRepository $repository,
		SaveSettingsUseCase $saveUseCase,
		SettingsFormParser $parser,
		?callable $capProbe = null,
		?callable $nonceVerifier = null,
		?callable $redirector = null,
		?callable $urlBuilder = null,
		?AuditEventWriter $auditWriter = null,
		?callable $userIdResolver = null,
		?OptionStoreAffiliateRoutingRepository $affiliateRoutingRepository = null,
		?RenderCache $renderCache = null
	) {
		$this->repository                 = $repository;
		$this->saveUseCase                = $saveUseCase;
		$this->parser                     = $parser;
		$this->capProbe                   = $capProbe ?? self::defaultCapProbe();
		$this->nonceVerifier              = $nonceVerifier ?? self::defaultNonceVerifier();
		$this->redirector                 = $redirector ?? self::defaultRedirector();
		$this->urlBuilder                 = $urlBuilder ?? self::defaultUrlBuilder();
		$this->auditWriter                = $auditWriter;
		$this->userIdResolver             = $userIdResolver ?? self::defaultUserIdResolver();
		$this->affiliateRoutingRepository = $affiliateRoutingRepository;
		$this->renderCache                = $renderCache;
	}

	/**
	 * Handle a single `admin-post.php?action=affilun_save_settings`
	 * POST request.
	 *
	 * See the file-level docblock for the four-stage flow
	 * (capability check → nonce check → parse → save) and the
	 * status-flash literal set the redirector receives.
	 *
	 * @param array<string, mixed> $postData `$_POST`-shaped payload.
	 */
	public function handle( array $postData ): void {
		if ( ! ( $this->capProbe )( AdminCapability::CAP_FALLBACK_MANAGE ) ) {
			$this->redirectWithStatus( 'forbidden' );
			return;
		}

		$nonceRaw   = $postData[ SettingsFormToken::NONCE_FIELD ] ?? '';
		$nonceValue = is_scalar( $nonceRaw ) ? self::sanitizeNonceValue( (string) $nonceRaw ) : '';
		if ( ! ( $this->nonceVerifier )( SettingsFormToken::NONCE_ACTION, $nonceValue ) ) {
			$this->redirectWithStatus( 'invalid_nonce' );
			return;
		}

		$current = $this->repository->load();

		$rawSettings = $postData[ SettingsFormToken::FORM_FIELD_PREFIX ] ?? array();
		if ( ! is_array( $rawSettings ) ) {
			$rawSettings = array();
		}

		$rawSettings = $this->prepareYahooValueCommerceCredentials( $rawSettings );
		if ( $rawSettings === null ) {
			$this->redirectWithStatus( 'yahoo_vc_invalid' );
			return;
		}

		$newSettings = $this->parser->parse( $rawSettings, $current );

		try {
			$this->saveUseCase->execute( $newSettings );
			$this->saveAffiliateRouting( $rawSettings );
			$this->flushRenderCache();
		} catch ( Throwable $unused ) {
			unset( $unused );
			$this->redirectWithStatus( 'error' );
			return;
		}

		$this->redirectWithStatus( 'saved' );
	}

	/**
	 * Flush rendered card HTML after settings changes.
	 */
	private function flushRenderCache(): void {
		if ( $this->renderCache === null ) {
			return;
		}

		try {
			$this->renderCache->flush();
		} catch ( Throwable $unused ) {
			unset( $unused );
		}
	}

	/**
	 * Save ASP affiliate-routing settings posted through the main settings form.
	 *
	 * @param array<string, mixed> $rawSettings Form payload rooted at `affilun_settings`.
	 */
	private function saveAffiliateRouting( array $rawSettings ): void {
		if ( $this->affiliateRoutingRepository === null ) {
			return;
		}

		$section = $rawSettings['affiliate_routing'] ?? array();
		if ( ! is_array( $section ) ) {
			return;
		}

		$rawTemplates = $section['templates'] ?? array();
		$templates    = array();
		if ( is_array( $rawTemplates ) ) {
			foreach ( OptionStoreAffiliateRoutingRepository::PROVIDERS as $provider ) {
				$value                  = $rawTemplates[ $provider ] ?? '';
				$templates[ $provider ] = is_scalar( $value ) ? self::unslash( (string) $value ) : '';
			}
		}

		$this->affiliateRoutingRepository->saveRaw(
			$templates,
			self::readBool( $section, 'enabled' )
		);
	}

	/**
	 * Extract Yahoo ValueCommerce sid / pid from a pasted HTML or URL field.
	 *
	 * The pasted blob is intentionally never persisted or rendered back. This
	 * method only saves numeric sid / pid values after decoding common
	 * ValueCommerce link formats and validating both values against a strict
	 * allow-list.
	 *
	 * @param array<string, mixed> $rawSettings Form payload rooted at `affilun_settings`.
	 *
	 * @return array<string, mixed>|null Prepared settings, or null when extraction failed.
	 */
	private function prepareYahooValueCommerceCredentials( array $rawSettings ): ?array {
		$providers = $rawSettings['providers'] ?? array();
		if ( ! is_array( $providers ) ) {
			return $rawSettings;
		}

		$yahoo = $providers[ self::YAHOO_PROVIDER_KEY ] ?? array();
		if ( ! is_array( $yahoo ) || ! array_key_exists( self::YAHOO_VALUECOMMERCE_HTML_FIELD, $yahoo ) ) {
			return $rawSettings;
		}

		$rawPaste = $yahoo[ self::YAHOO_VALUECOMMERCE_HTML_FIELD ];
		$paste    = is_scalar( $rawPaste ) ? trim( self::unslash( (string) $rawPaste ) ) : '';
		if ( $paste === '' ) {
			return $rawSettings;
		}

		$ids = self::extractValueCommerceSidPid( $paste );
		if ( $ids === null ) {
			return null;
		}

		$rawSettings['providers'][ self::YAHOO_PROVIDER_KEY ]['affiliate_sid'] = $ids['sid'];
		$rawSettings['providers'][ self::YAHOO_PROVIDER_KEY ]['affiliate_pid'] = $ids['pid'];
		unset( $rawSettings['providers'][ self::YAHOO_PROVIDER_KEY ][ self::YAHOO_VALUECOMMERCE_HTML_FIELD ] );

		return $rawSettings;
	}

	/**
	 * Extract numeric ValueCommerce sid / pid from pasted HTML or URL text.
	 *
	 * @param string $paste Raw pasted text.
	 *
	 * @return array{sid:string,pid:string}|null Extracted ids, or null on failure.
	 */
	private static function extractValueCommerceSidPid( string $paste ): ?array {
		if ( strlen( $paste ) > self::VALUECOMMERCE_HTML_MAX_BYTES ) {
			return null;
		}

		$normalized = str_replace( "\0", '', $paste );
		$decoded    = html_entity_decode( $normalized, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$candidates = array( $decoded );
		$urlDecoded = rawurldecode( $decoded );
		if ( $urlDecoded !== $decoded ) {
			$candidates[] = $urlDecoded;
		}

		foreach ( $candidates as $candidate ) {
			$matchCount = preg_match_all(
				'#https?://ck\.jp\.ap\.valuecommerce\.com/servlet/referral\?[^\\s"\'<>]+#i',
				$candidate,
				$matches
			);
			if ( $matchCount === false || $matchCount === 0 ) {
				$fallback = self::extractValueCommerceSidPidFromReferralText( $candidate );
				if ( $fallback !== null ) {
					return $fallback;
				}
				continue;
			}

			foreach ( $matches[0] as $url ) {
				if ( preg_match( '/\?([^#]*)/', $url, $queryMatch ) !== 1 ) {
					continue;
				}
				$query = $queryMatch[1];

				parse_str( $query, $parameters );
				$sid = $parameters['sid'] ?? null;
				$pid = $parameters['pid'] ?? null;
				if ( is_string( $sid ) && is_string( $pid )
					&& preg_match( '/\A[0-9]{1,20}\z/', $sid ) === 1
					&& preg_match( '/\A[0-9]{1,20}\z/', $pid ) === 1
				) {
					return array(
						'sid' => $sid,
						'pid' => $pid,
					);
				}
			}
		}

		return null;
	}

	/**
	 * Extract sid / pid from text that contains a referral marker but no clean URL.
	 *
	 * @param string $candidate Decoded pasted text candidate.
	 *
	 * @return array{sid:string,pid:string}|null Extracted ids, or null.
	 */
	private static function extractValueCommerceSidPidFromReferralText( string $candidate ): ?array {
		if ( stripos( $candidate, 'ck.jp.ap.valuecommerce.com/servlet/referral' ) === false ) {
			return null;
		}

		if ( preg_match( '/(?:\?|&|&amp;)sid=([0-9]{1,20})(?:[^0-9][\s\S]*?)(?:\?|&|&amp;)pid=([0-9]{1,20})/i', $candidate, $matches ) !== 1 ) {
			return null;
		}

		return array(
			'sid' => $matches[1],
			'pid' => $matches[2],
		);
	}

	/**
	 * Read a checkbox-style boolean from a form payload.
	 *
	 * @param array<string, mixed> $source Source array.
	 * @param string               $key    Field key.
	 */
	private static function readBool( array $source, string $key ): bool {
		if ( ! array_key_exists( $key, $source ) ) {
			return false;
		}

		$value = $source[ $key ];
		return ! ( $value === false || $value === 0 || $value === '0' || $value === '' || $value === null );
	}

	/**
	 * Unslash a posted scalar value when WordPress helpers are available.
	 *
	 * @param string $value Posted value.
	 */
	private static function unslash( string $value ): string {
		return function_exists( 'wp_unslash' ) ? (string) wp_unslash( $value ) : $value;
	}

	/**
	 * Build the redirect URL by appending `affilun_status=$status`
	 * to the settings-page URL, emit a §10.1 audit row keyed on
	 * the same status (when an audit writer is wired), and
	 * forward through the injected redirector.
	 *
	 * The `add_query_arg` global is used when available; the
	 * fallback string concatenation keeps the handler testable
	 * in pure-unit harnesses where WordPress is not loaded.
	 *
	 * Audit emission runs BEFORE the redirector so production
	 * hosts (whose default redirector calls `exit`) never skip
	 * the audit row. Audit failures are swallowed inside
	 * {@see self::emitAudit()} per the {@see AuditEventWriter}
	 * "tolerate audit emit failure" contract — losing one
	 * audit row must never mask the business outcome.
	 *
	 * @param string $status One of `'saved'` / `'error'` /
	 *                       `'forbidden'` / `'invalid_nonce'`.
	 */
	private function redirectWithStatus( string $status ): void {
		$this->emitAudit( $status );

		$base = ( $this->urlBuilder )();
		$url  = self::appendStatusQueryArg( $base, $status );
		( $this->redirector )( $url );
	}

	/**
	 * Emit a `configuration_change` audit row describing the
	 * terminal outcome of {@see self::handle()}.
	 *
	 * Status → §10.2 result token mapping:
	 *
	 *   - `saved`         → {@see AuditEventDraft::RESULT_SUCCESS}
	 *   - `error`         → {@see AuditEventDraft::RESULT_FAILED}
	 *   - `forbidden`     → {@see AuditEventDraft::RESULT_REJECTED}
	 *   - `invalid_nonce` → {@see AuditEventDraft::RESULT_REJECTED}
	 *
	 * No-op when no audit writer was injected at construction
	 * time (production wiring may opt in via
	 * {@see \Affilun\Presentation\Admin\AdminServiceProvider};
	 * the pure-unit handler tests stay silent so they remain
	 * focused on the redirector contract).
	 *
	 * Failure semantics: a {@see RuntimeException} from the
	 * writer is swallowed here so the surrounding redirect path
	 * is not derailed — losing one audit row is preferable to
	 * losing the business operation it described, per the
	 * {@see AuditEventWriter} contract.
	 *
	 * @param string $status One of `'saved'` / `'error'` /
	 *                       `'forbidden'` / `'invalid_nonce'`.
	 */
	private function emitAudit( string $status ): void {
		if ( $this->auditWriter === null ) {
			return;
		}

		$result  = self::resultTokenForStatus( $status );
		$summary = self::safeSummaryForStatus( $status );
		$context = array( 'affilun_status' => $status );

		$resolver = $this->userIdResolver;
		$userId   = $resolver();

		try {
			if ( $userId !== null ) {
				$draft = AuditEventDraft::forUserAction(
					'settings.save',
					'configuration_change',
					'update',
					$result,
					$summary,
					$userId,
					'settings',
					'plugin',
					array(),
					$context
				);
			} else {
				$draft = AuditEventDraft::forSystemAction(
					'settings.save',
					'configuration_change',
					'update',
					$result,
					$summary,
					'settings',
					'plugin',
					array(),
					$context
				);
			}

			$this->auditWriter->record( $draft );
		} catch ( RuntimeException $unused ) {
			// Per the AuditEventWriter contract, the call site
			// log-and-continues on persistence failure; the
			// business outcome (saved / error / forbidden /
			// invalid_nonce) MUST still flow to the redirector.
			unset( $unused );
		}
	}

	/**
	 * Map a redirect status literal onto the §10.2 audit result
	 * token.
	 *
	 * @param string $status Redirect status literal.
	 */
	private static function resultTokenForStatus( string $status ): string {
		switch ( $status ) {
			case 'saved':
				return AuditEventDraft::RESULT_SUCCESS;
			case 'error':
				return AuditEventDraft::RESULT_FAILED;
			case 'forbidden':
			case 'invalid_nonce':
			default:
				return AuditEventDraft::RESULT_REJECTED;
		}
	}

	/**
	 * Build the human-readable safe summary for a redirect
	 * status. The summary deliberately carries no secret
	 * material — every persisted value (including provider
	 * secrets) is omitted so the §6.4 "do not leak persistence
	 * details" rule is honored.
	 *
	 * @param string $status Redirect status literal.
	 */
	private static function safeSummaryForStatus( string $status ): string {
		switch ( $status ) {
			case 'saved':
				return 'Settings save accepted by the §13 admin form.';
			case 'error':
				return 'Settings save failed in the persistence layer.';
			case 'forbidden':
				return 'Settings save denied by the capability gate.';
			case 'invalid_nonce':
				return 'Settings save rejected by the nonce verifier.';
			default:
				return sprintf( 'Settings save terminal status "%s".', $status );
		}
	}

	/**
	 * Append the `affilun_status` query argument to `$base`,
	 * preferring the WordPress `add_query_arg()` helper and
	 * falling back to manual string concatenation when the
	 * helper is unavailable.
	 *
	 * @param string $base   Base URL.
	 * @param string $status Status literal.
	 */
	private static function appendStatusQueryArg( string $base, string $status ): string {
		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( array( 'affilun_status' => $status ), $base );
		}

		$separator = str_contains( $base, '?' ) ? '&' : '?';
		return $base . $separator . 'affilun_status=' . rawurlencode( $status );
	}

	/**
	 * Default capability probe.
	 *
	 * Forwards to `current_user_can()` when available; returns
	 * false otherwise so the pure-unit harness fails closed.
	 *
	 * @phpstan-return callable(string): bool
	 */
	private static function defaultCapProbe(): callable {
		return static function ( string $capability ): bool {
			if ( ! function_exists( 'current_user_can' ) ) {
				return false;
			}
			return (bool) current_user_can( $capability );
		};
	}

	/**
	 * Default nonce verifier.
	 *
	 * Forwards to `wp_verify_nonce()` when available; returns
	 * false otherwise so the pure-unit harness fails closed.
	 * The argument order is swapped here because WordPress's
	 * native signature is `wp_verify_nonce($nonce, $action)`
	 * but this handler invokes the verifier as
	 * `($action, $value)` for consistency with
	 * {@see SettingsFormToken::validateNonce()}.
	 *
	 * @phpstan-return callable(string, string): bool
	 */
	private static function defaultNonceVerifier(): callable {
		return static function ( string $action, string $nonceValue ): bool {
			if ( ! function_exists( 'wp_verify_nonce' ) ) {
				return false;
			}
			return (bool) wp_verify_nonce( $nonceValue, $action );
		};
	}

	/**
	 * Sanitize a nonce string before verification.
	 *
	 * @param string $value Raw nonce.
	 */
	private static function sanitizeNonceValue( string $value ): string {
		$value = function_exists( 'wp_unslash' ) ? (string) wp_unslash( $value ) : $value;
		if ( function_exists( 'sanitize_text_field' ) ) {
			return (string) sanitize_text_field( $value );
		}
		$value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value;
		return trim( (string) preg_replace( '/\s+/', ' ', $value ) );
	}

	/**
	 * Default redirector.
	 *
	 * Forwards to `wp_safe_redirect()` and then calls `exit;`
	 * so the surrounding `admin-post.php` dispatcher does not
	 * continue and emit a second response. Tests MUST inject a
	 * recording closure that returns without calling `exit;`.
	 *
	 * @phpstan-return callable(string): void
	 */
	private static function defaultRedirector(): callable {
		return static function ( string $url ): void {
			if ( function_exists( 'wp_safe_redirect' ) ) {
				wp_safe_redirect( $url );
				exit;
			}
			// In production hosts WordPress short-circuits the
			// rest of admin-post.php on its own; tests inject an
			// exit-free recorder so PHPUnit can keep running.
			exit;
		};
	}

	/**
	 * Default settings-page URL builder.
	 *
	 * Forwards to `admin_url()` when available; falls back to a
	 * synthetic absolute URL in pure-unit harnesses where
	 * WordPress is not loaded.
	 *
	 * @phpstan-return callable(): string
	 */
	private static function defaultUrlBuilder(): callable {
		return static function (): string {
			if ( function_exists( 'admin_url' ) ) {
				return (string) admin_url( 'options-general.php?page=affilun-settings' );
			}
			return '/wp-admin/options-general.php?page=affilun-settings';
		};
	}

	/**
	 * Default current-user-id resolver.
	 *
	 * Forwards to `get_current_user_id()` when available, returning
	 * the integer id (or null when no user is logged in / the
	 * helper is unavailable so the audit row downgrades to a
	 * system action rather than recording an unknown actor as id 0).
	 *
	 * @phpstan-return callable(): ?int
	 */
	private static function defaultUserIdResolver(): callable {
		return static function (): ?int {
			if ( ! function_exists( 'get_current_user_id' ) ) {
				return null;
			}
			$id = (int) get_current_user_id();
			return $id !== 0 ? $id : null;
		};
	}
}
