<?php
/**
 * Save-settings POST handler.
 *
 * Phase-8 slice 8c-3 Presentation-layer handler that drives the
 * `admin-post.php?action=clb_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 \CommerceLinkBuilder\Presentation\Admin\AdminServiceProvider}.
 *      Failure redirects with `clb_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 `clb_status=invalid_nonce`.
 *
 *   3. Parses the namespaced `clb_settings` sub-array into a
 *      {@see \CommerceLinkBuilder\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
 *      `clb_status=saved`; any {@see \Throwable} from the use
 *      case (typically a repository-layer failure) redirects
 *      with `clb_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 `clb_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 CommerceLinkBuilder\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Admin\Page;

use CommerceLinkBuilder\Application\Audit\AuditEventDraft;
use CommerceLinkBuilder\Application\Audit\AuditEventWriter;
use CommerceLinkBuilder\Application\Settings\SettingsRepository;
use CommerceLinkBuilder\Application\Settings\UseCase\SaveSettingsUseCase;
use CommerceLinkBuilder\Presentation\Admin\AdminCapability;
use RuntimeException;
use Throwable;

/**
 * Drives the §13 Settings page's `admin-post.php?action=clb_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 `clb_status` query
 * parameter.
 */
final class SaveSettingsHandler {

	/**
	 * §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['clb_settings']` sub-
	 * array into a {@see \CommerceLinkBuilder\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 `clb_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;

	/**
	 * 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; when null,
	 *                                              the handler does not emit audit rows.
	 * @param callable|null         $userIdResolver Optional `(): ?int` resolver for the
	 *                                              current-user id used in audit rows.
	 *
	 * @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
	) {
		$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();
	}

	/**
	 * Handle a single `admin-post.php?action=clb_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 ) ? (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();
		}

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

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

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

	/**
	 * Build the redirect URL by appending `clb_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 \CommerceLinkBuilder\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( 'clb_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 `clb_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( 'clb_status' => $status ), $base );
		}

		$separator = str_contains( $base, '?' ) ? '&' : '?';
		return $base . $separator . 'clb_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 );
		};
	}

	/**
	 * 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 );
			}
			// 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=clb-settings' );
			}
			return '/wp-admin/options-general.php?page=clb-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;
		};
	}
}
