<?php
/**
 * REST endpoint that receives and dispatches PayPal webhooks per account.
 *
 * @since   1.0.0
 * @package DonationRouter
 */

declare( strict_types=1 );
namespace DonationRouter;

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

/**
 * Routes inbound PayPal webhook events to the correct account context.
 *
 * Each account managed by this plugin registers a unique webhook URL of the
 * form `/wp-json/ador/v1/webhook/{account_id}`.  The account
 * ID in the URL tells us which account's webhook ID and credentials to use for
 * signature verification before forwarding the event to GiveWP's native event
 * dispatch stack.
 *
 * Constraints enforced:
 *  - Every event is verified against PayPal's signature API before processing.
 *  - Duplicate events (same PayPal event ID) return 200 without re-processing.
 *  - Sensitive data (tokens, full payloads) is never written to logs.
 *  - The handler always responds within PayPal's 30-second timeout window.
 *
 * @since 1.0.0
 */
class Webhook_Router {

	/**
	 * REST API namespace.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	public const REST_NAMESPACE = 'ador/v1';

	/**
	 * REST route pattern.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	public const REST_ROUTE = '/webhook/(?P<account_id>\\d+)';

	/**
	 * Transient key prefix for idempotency checks.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	private const IDEMPOTENCY_PREFIX = 'ador_wh_';

	/**
	 * Account data source.
	 *
	 * @since 1.0.0
	 *
	 * @var Account_Repository_Interface
	 */
	private Account_Repository_Interface $account_repo;

	/**
	 * Signature verifier.
	 *
	 * @since 1.0.0
	 *
	 * @var Webhook_Signature_Verifier_Interface
	 */
	private Webhook_Signature_Verifier_Interface $verifier;

	/**
	 * Credential router used to substitute account credentials before dispatch.
	 *
	 * @since 1.0.0
	 *
	 * @var PayPal_Credential_Router
	 */
	private PayPal_Credential_Router $credential_router;

	/**
	 * Optional event dispatcher override — null means use GiveWP's WebhookRegister.
	 *
	 * Signature: callable(string $event_type, object $event): void
	 *
	 * @since 1.0.0
	 *
	 * @var callable|null
	 */
	private $event_dispatcher;

	/**
	 * Mints fresh PayPal access tokens from an account's own credentials.
	 *
	 * @since 1.1.0
	 *
	 * @var PayPal_Access_Token_Client
	 */
	private PayPal_Access_Token_Client $token_client;

	/**
	 * Initialise dependencies.
	 *
	 * @since 1.0.0
	 *
	 * @param Account_Repository_Interface         $account_repo       Account data source.
	 * @param Webhook_Signature_Verifier_Interface $verifier           Signature verifier.
	 * @param PayPal_Credential_Router             $credential_router  IoC rebinder for per-account credentials.
	 * @param callable|null                        $event_dispatcher   Override dispatcher (tests only).
	 * @param PayPal_Access_Token_Client|null      $token_client       Token source; defaults to a real client.
	 */
	public function __construct(
		Account_Repository_Interface $account_repo,
		Webhook_Signature_Verifier_Interface $verifier,
		PayPal_Credential_Router $credential_router,
		?callable $event_dispatcher = null,
		?PayPal_Access_Token_Client $token_client = null
	) {
		$this->account_repo      = $account_repo;
		$this->verifier          = $verifier;
		$this->credential_router = $credential_router;
		$this->event_dispatcher  = $event_dispatcher;
		$this->token_client      = $token_client ?? new PayPal_Access_Token_Client();
	}

	/**
	 * Register the REST route on `rest_api_init`.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function register_hooks(): void {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	/**
	 * Register the per-account webhook REST route.
	 *
	 * `permission_callback` is intentionally `__return_true`: this endpoint is
	 * called by PayPal's webhook servers, which are unauthenticated from
	 * WordPress's point of view (no logged-in user, no nonce, no cookie). The
	 * request is authenticated inside `handle()` by verifying PayPal's
	 * HMAC-SHA256 signature against the configured webhook ID before any
	 * state-changing work is performed. Requests with an invalid or missing
	 * signature are rejected with HTTP 401.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function register_routes(): void {
		register_rest_route(
			self::REST_NAMESPACE,
			self::REST_ROUTE,
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'handle' ),
				'permission_callback' => '__return_true',
			)
		);
	}

	/**
	 * Handle an inbound PayPal webhook event.
	 *
	 * @since 1.0.0
	 *
	 * @param \WP_REST_Request $request  Incoming REST request.
	 *
	 * @return \WP_REST_Response
	 */
	public function handle( \WP_REST_Request $request ): \WP_REST_Response {
		// 1 — Parse JSON payload.
		$event = json_decode( $request->get_body() );
		if ( ! is_object( $event ) ) {
			return new \WP_REST_Response(
				array(
					'code'    => 'invalid_payload',
					'message' => 'Request body is not valid JSON.',
				),
				400
			);
		}

		// 2 — Idempotency: skip duplicate event IDs.
		$event_id = sanitize_key( (string) ( $event->id ?? '' ) );
		if ( '' !== $event_id && false !== get_transient( self::IDEMPOTENCY_PREFIX . $event_id ) ) {
			return new \WP_REST_Response(
				array(
					'code'    => 'duplicate',
					'message' => 'Event already processed.',
				),
				200
			);
		}

		// 3 — Look up account. A genuinely missing account is a 404, but any other
		// failure (e.g. a stored account whose credentials no longer decrypt after a
		// salt rotation, or a DB error) must NOT masquerade as "not found": that
		// would silently drop every webhook for a live account with no trail. Log it
		// and return 500 so PayPal keeps retrying and the operator has a signal.
		$account_id = absint( $request->get_param( 'account_id' ) );
		try {
			$account = $this->account_repo->find_by_id( $account_id );
		} catch ( Account_Not_Found_Exception $e ) {
			return new \WP_REST_Response(
				array(
					'code'    => 'account_not_found',
					'message' => 'Account not found.',
				),
				404
			);
		} catch ( \Throwable $e ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log(
				sprintf(
					'[ADOR] Webhook account lookup failed. account_id=%d error=%s',
					$account_id,
					$e->getMessage()
				)
			);
			return new \WP_REST_Response(
				array(
					'code'    => 'account_lookup_error',
					'message' => 'Account could not be loaded; PayPal will retry.',
				),
				500
			);
		}

		if ( ! $account->is_active ) {
			return new \WP_REST_Response(
				array(
					'code'    => 'account_inactive',
					'message' => 'Account is inactive.',
				),
				404
			);
		}

		if ( null === $account->webhook_id || '' === $account->webhook_id ) {
			return new \WP_REST_Response(
				array(
					'code'    => 'webhook_not_configured',
					'message' => 'No webhook ID registered for this account.',
				),
				404
			);
		}

		// 4 — Verify signature (result cached 5 min per transmission ID).
		$raw_headers = $request->get_headers();
		$headers     = array_map(
			static fn( mixed $val ) => is_array( $val ) ? (string) ( $val[0] ?? '' ) : (string) $val,
			$raw_headers
		);

		// Mint a fresh access token from the account's own credentials in its own
		// environment — the token snapshot captured at import expires within hours,
		// and the account's mode (not the site's) selects the PayPal API.
		try {
			$access_token = $this->token_client->get_token( $account );
		} catch ( \Throwable $e ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log(
				sprintf(
					'[ADOR] Webhook token mint failed. account_id=%d error=%s',
					$account_id,
					$e->getMessage()
				)
			);
			return new \WP_REST_Response(
				array(
					'code'    => 'auth_error',
					'message' => 'Could not authenticate with PayPal; PayPal will retry.',
				),
				500
			);
		}

		$verified = $this->verifier->verify( $event, $headers, $account->webhook_id, $access_token, $account->mode );

		if ( ! $verified ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log(
				sprintf(
					'[ADOR] Webhook signature invalid. account_id=%d event_type=%s',
					$account_id,
					// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
					sanitize_key( (string) ( $event->event_type ?? 'unknown' ) )
				)
			);
			return new \WP_REST_Response(
				array(
					'code'    => 'signature_invalid',
					'message' => 'Signature verification failed.',
				),
				401
			);
		}

		// 5 — Substitute account credentials before dispatching, installing the
		// live token so refund handlers that read MerchantDetail->accessToken
		// directly authenticate with a fresh (not connect-time) token.
		$this->credential_router->route_credentials_for_account( $account, $access_token );

		// 6 — Dispatch to GiveWP event handler.
		$event_type = (string) ( $event->event_type ?? '' );
		try {
			$this->dispatch_event( $event_type, $event );
		} catch ( \Throwable $e ) {
			// A failed dispatch must NOT be marked processed. Returning 500 lets
			// PayPal redeliver the event (per its retry schedule) instead of
			// silently dropping it — e.g. a PAYMENT.CAPTURE.COMPLETED that failed
			// to apply would otherwise never finalize the donation. Duplicate
			// redeliveries are absorbed by the idempotency check at step 2, which
			// only trips once a dispatch has actually succeeded (below).
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log(
				sprintf(
					'[ADOR] Webhook dispatch error. account_id=%d event_type=%s error=%s',
					$account_id,
					// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
					sanitize_key( $event_type ),
					$e->getMessage()
				)
			);
			return new \WP_REST_Response(
				array(
					'code'    => 'dispatch_error',
					'message' => 'Event processing failed; PayPal will retry.',
				),
				500
			);
		}

		// 7 — Mark this event ID as processed only after a SUCCESSFUL dispatch
		// (24-hour idempotency window).
		if ( '' !== $event_id ) {
			set_transient( self::IDEMPOTENCY_PREFIX . $event_id, '1', DAY_IN_SECONDS );
		}

		/**
		 * Fires after a webhook event has been successfully processed.
		 *
		 * @since 1.0.0
		 *
		 * @param int    $account_id  Account that owned the webhook.
		 * @param string $event_type  PayPal event type string.
		 * @param string $event_id    Unique PayPal event ID.
		 */
		do_action( 'ador_webhook_processed', $account_id, $event_type, $event_id );

		return new \WP_REST_Response(
			array(
				'code'    => 'ok',
				'message' => 'Processed.',
			),
			200
		);
	}

	/**
	 * Build the webhook URL for a given account.
	 *
	 * This URL should be registered in the PayPal developer dashboard under
	 * the corresponding app's webhook subscriptions.
	 *
	 * @since 1.0.0
	 *
	 * @param int $account_id  Account post ID.
	 *
	 * @return string  Fully-qualified HTTPS webhook URL.
	 */
	public static function get_webhook_url( int $account_id ): string {
		return rest_url( self::REST_NAMESPACE . '/webhook/' . $account_id );
	}

	/**
	 * Dispatch an event to GiveWP's native WebhookRegister, or to the injected
	 * dispatcher (when running under test).
	 *
	 * @since 1.0.0
	 *
	 * @param string $event_type  PayPal event type string.
	 * @param object $event       Full decoded event payload.
	 *
	 * @return void
	 *
	 * @throws \Throwable  Re-throws any exception from the event handler.
	 */
	private function dispatch_event( string $event_type, object $event ): void {
		if ( null !== $this->event_dispatcher ) {
			( $this->event_dispatcher )( $event_type, $event );
			return;
		}

		$register = give( \Give\PaymentGateways\PayPalCommerce\Webhooks\WebhookRegister::class );
		if ( ! $register->hasEventRegistered( $event_type ) ) {
			return;
		}
		$register->getEventHandler( $event_type )->processEvent( $event );
	}
}
